littlejsengine 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,8 +52,23 @@ function tweak(){}
52
52
  function tweakButton(){}
53
53
  function tweakDivider(){}
54
54
  function tweakEngineDefaults(){}
55
- const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined,
56
- paletteTiles: undefined, use3D: undefined};
55
+ class LevelEditor
56
+ {
57
+ constructor()
58
+ {
59
+ this.paletteTiles = this.use3D = this.tool = undefined;
60
+ this.keys = {}, this.buttons = [], this.tools = {};
61
+ }
62
+ get isOpen() { return false; }
63
+ get is3D() { return false; }
64
+ get edit2D() { return undefined; }
65
+ get edit3D() { return undefined; }
66
+ open(){} close(){} addKey(){} addButton(){} addTool(){}
67
+ onTile(){} onRestart(){} onPlayFrom(){} onOpen(){} onClose(){} onUpdate(){} onDraw(){} onPanel(){}
68
+ onSave(){ return false; }
69
+ }
70
+ let levelEditor = new LevelEditor;
71
+ function setLevelEditor(editor){ levelEditor = editor; }
57
72
  function editorMapRestore(map){ return map; }
58
73
  function editorMapLoaded(){}
59
74
  function editorJSONFetched(){}
@@ -313,6 +313,15 @@ let touchGamepadEnable = false;
313
313
  * @memberof Settings */
314
314
  let touchGamepadPassthrough = false;
315
315
 
316
+ /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with
317
+ * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button
318
+ * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the
319
+ * first finger stays the mouse and touchPinch can still be read
320
+ * @type {boolean}
321
+ * @default
322
+ * @memberof Settings */
323
+ let touchPinchWheel = true;
324
+
316
325
  /** Size of center button if touch gamepad should have start button in the center
317
326
  * - Prevents activating when pressed near virtual stick or face buttons
318
327
  * - When the game is paused, any touch will press the button
@@ -708,6 +717,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
708
717
  * @memberof Settings */
709
718
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
710
719
 
720
+ /** Set if a pinch of two fingers turns the mouse wheel
721
+ * @param {boolean} enable
722
+ * @memberof Settings */
723
+ function setTouchPinchWheel(enable) { touchPinchWheel = enable; }
724
+
711
725
  /** Set if touches outside the gamepad controls should still drive mouse/touch input
712
726
  * @param {boolean} passthrough
713
727
  * @memberof Settings */
@@ -331,7 +331,8 @@ function objectLayersMake(tileMapData, object)
331
331
  const name = object.type || object.class, type = objectLayersTypes.get(name);
332
332
  if (!type)
333
333
  {
334
- debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
334
+ // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes
335
+ debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
335
336
  return;
336
337
  }
337
338
  const {height=0, tilewidth=1, tileheight=1} = tileMapData;
@@ -1,292 +1,292 @@
1
- /**
2
- * LittleJS Utility Classes and Functions
3
- * - Timer - tracks time automatically with support for pause and real-time modes
4
- * - Time formatting helper
5
- * - JSON file fetching
6
- * - File saving (text, canvas, data URLs)
7
- * - Native share dialog support
8
- * - Local storage save data management
9
- * - Value noise (1D and 2D)
10
- * @namespace Utilities
11
- */
12
-
13
- 'use strict';
14
-
15
- /**
16
- * Timer object tracks how long has passed since it was set
17
- * @memberof Engine
18
- * @example
19
- * let a = new Timer; // creates a timer that is not set
20
- * a.set(3); // sets the timer to 3 seconds
21
- *
22
- * let b = new Timer(1); // creates a timer with 1 second left
23
- * b.unset(); // unset the timer
24
- */
25
- class Timer
26
- {
27
- /** Create a timer object set time passed in
28
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
29
- * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
30
- constructor(timeLeft, useRealTime=false)
31
- {
32
- ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
33
- this.useRealTime = useRealTime;
34
- const globalTime = this.getGlobalTime();
35
- /** @type {number|undefined} */
36
- this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
37
- /** @type {number|undefined} */
38
- this.setTime = timeLeft;
39
- }
40
-
41
- /** Set the timer with seconds passed in
42
- * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
43
- set(timeLeft=0)
44
- {
45
- ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
46
- const globalTime = this.getGlobalTime();
47
- this.time = globalTime + timeLeft;
48
- this.setTime = timeLeft;
49
- }
50
-
51
- /** Set if the timer should keep running even when the game is paused
52
- * @param {boolean} [useRealTime] */
53
- setUseRealTime(useRealTime=true)
54
- {
55
- ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
56
- this.useRealTime = useRealTime;
57
- }
58
-
59
- /** Unset the timer */
60
- unset() { this.time = undefined; }
61
-
62
- /** Returns true if set
63
- * @return {boolean} */
64
- isSet() { return this.time !== undefined; }
65
-
66
- /** Returns true if set and has not elapsed
67
- * @return {boolean} */
68
- active() { return this.getGlobalTime() < this.time; }
69
-
70
- /** Returns true if set and elapsed
71
- * @return {boolean} */
72
- elapsed() { return this.getGlobalTime() >= this.time; }
73
-
74
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
75
- * @return {number} */
76
- get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
77
-
78
- /** Get percentage elapsed based on time it was set to, returns 0 if not set.
79
- * Zero-duration timers report 1 (already elapsed).
80
- * @return {number} */
81
- getPercent()
82
- {
83
- if (!this.isSet()) return 0;
84
- if (!this.setTime) return 1;
85
- return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
86
- }
87
-
88
- /** Get the time this timer was set to, returns 0 if not set
89
- * @return {number} */
90
- getSetTime() { return this.isSet() ? this.setTime : 0; }
91
-
92
- /** Get the current global time this timer is based on
93
- * @return {number} */
94
- getGlobalTime() { return this.useRealTime ? timeReal : time; }
95
-
96
- /** Returns this timer expressed as a string
97
- * @return {string} */
98
- toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
99
-
100
- /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
101
- * @return {number} */
102
- valueOf() { return this.get(); }
103
- }
104
-
105
- ///////////////////////////////////////////////////////////////////////////////
106
-
107
- /** Formats seconds to mm:ss style for display purposes
108
- * @param {number} t - time in seconds
109
- * @return {string}
110
- * @memberof Utilities */
111
- function formatTime(t)
112
- {
113
- const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
114
- t = abs(t)|0;
115
- return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
116
- }
117
-
118
- /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
119
- * @param {string} url - URL of JSON file
120
- * @return {Promise<object>}
121
- * @memberof Utilities */
122
- async function fetchJSON(url)
123
- {
124
- const response = await fetch(url);
125
- if (!response.ok)
126
- throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
127
- const json = await response.json();
128
- editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
129
- return json;
130
- }
131
-
132
- ///////////////////////////////////////////////////////////////////////////////
133
-
134
- /** Save a text file to disk
135
- * @param {string} text
136
- * @param {string} [filename]
137
- * @param {string} [type]
138
- * @memberof Utilities */
139
- function saveText(text, filename='text', type='text/plain')
140
- { saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
141
-
142
- /** Create an offscreen canvas to draw into, and return its 2D context
143
- * - The canvas is context.canvas, which is what TextureInfo and the like take
144
- * @param {number} width - In pixels
145
- * @param {number} [height] - In pixels, defaults to the width for a square
146
- * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
147
- * @return {OffscreenCanvasRenderingContext2D}
148
- * @memberof Utilities */
149
- function createCanvasContext(width, height=width, willReadFrequently=false)
150
- {
151
- ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
152
- return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
153
- }
154
-
155
- /** Save a canvas to disk
156
- * @param {HTMLCanvasElement|OffscreenCanvas} canvas
157
- * @param {string} [filename]
158
- * @param {string} [type]
159
- * @memberof Utilities */
160
- function saveCanvas(canvas, filename='screenshot', type='image/png')
161
- {
162
- if (canvas instanceof OffscreenCanvas)
163
- {
164
- // copy to temporary canvas and save
165
- const saveCanvas = document.createElement('canvas');
166
- saveCanvas.width = canvas.width;
167
- saveCanvas.height = canvas.height;
168
- saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
169
- saveDataURL(saveCanvas.toDataURL(type), filename);
170
- }
171
- else
172
- saveDataURL(canvas.toDataURL(type), filename);
173
- }
174
-
175
- /** Save a data url to disk
176
- * @param {string} url
177
- * @param {string} [filename]
178
- * @param {number} [revokeTime] - how long before revoking the url
179
- * @memberof Utilities */
180
- function saveDataURL(url, filename='download', revokeTime)
181
- {
182
- ASSERT(isStringLike(url), 'saveDataURL requires url string');
183
- ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
184
-
185
- // create link for saving screenshots
186
- const link = document.createElement('a');
187
- link.download = filename;
188
- link.href = url;
189
- link.click();
190
- if (revokeTime !== undefined)
191
- setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
192
- }
193
-
194
- /** Share content using the native share dialog if available
195
- * @param {string} title - title of the share
196
- * @param {string} url - url to share
197
- * @param {Function} [callback] - Called when share is complete
198
- * @memberof Utilities */
199
- function shareURL(title, url, callback)
200
- {
201
- ASSERT(isStringLike(title), 'shareURL requires title string');
202
- ASSERT(isStringLike(url), 'shareURL requires url string');
203
- navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
204
- }
205
-
206
- ///////////////////////////////////////////////////////////////////////////////
207
-
208
- /** Read save data from local storage
209
- * - The result has the type of defaultSaveData, or any when there is none
210
- * @template {Object<string, any>} [T=any]
211
- * @param {string} saveName - unique name for the game/save
212
- * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
213
- * @return {T}
214
- * @memberof Utilities */
215
- function readSaveData(saveName, defaultSaveData)
216
- {
217
- ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
218
- ASSERT(defaultSaveData === undefined ||
219
- (typeof defaultSaveData === 'object' && defaultSaveData !== null),
220
- 'readSaveData: default must be an object - the result is ' +
221
- '{...default, ...loaded}, so a scalar default yields {}. ' +
222
- 'Use readSaveData(key, {best:0}).best');
223
-
224
- // tolerate localStorage being unavailable (iOS private mode, sandboxed
225
- // iframes) and corrupt JSON in stored data
226
- let loadedData = {};
227
- try
228
- {
229
- const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
230
- if (data)
231
- {
232
- try { loadedData = JSON.parse(data); }
233
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
234
- }
235
- }
236
- catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
237
- return { .../** @type {object} */ (defaultSaveData), ...loadedData };
238
- }
239
-
240
- /** Write save data to local storage
241
- * @param {string} saveName - unique name for the game/save
242
- * @param {object} saveData - object containing data to be saved
243
- * @memberof Utilities */
244
- function writeSaveData(saveName, saveData)
245
- {
246
- ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
247
- // tolerate localStorage being unavailable or quota exceeded
248
- try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
249
- catch { LOG('writeSaveData: failed to write', saveName); }
250
- }
251
-
252
- ///////////////////////////////////////////////////////////////////////////////
253
-
254
- // Deterministic well-distributed hash of an integer lattice index to [0, 1).
255
- // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
256
- function noiseHash(i)
257
- {
258
- let h = (i | 0) ^ 0x9e3779b9;
259
- h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
260
- h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
261
- h ^= h >>> 16;
262
- return (h >>> 0) / 2**32;
263
- }
264
-
265
- /** 1D value noise — returns a smooth value in [0, 1] for any real x.
266
- * Integer inputs land on deterministic lattice values; non-integer inputs
267
- * are interpolated with smoothStep for C1 continuity.
268
- * @param {number} x
269
- * @return {number}
270
- * @memberof Utilities */
271
- function noise1D(x)
272
- {
273
- const i = floor(x);
274
- return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
275
- }
276
-
277
- /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
278
- * @param {number} x
279
- * @param {number} y
280
- * @return {number}
281
- * @memberof Utilities */
282
- function noise2D(x, y)
283
- {
284
- const ix = floor(x), iy = floor(y);
285
- const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
286
- // large prime decorrelates neighboring rows
287
- const h = (a, b) => noiseHash(a + b * 374761393);
288
- return lerp(
289
- lerp(h(ix, iy ), h(ix + 1, iy ), fx),
290
- lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
291
- fy);
1
+ /**
2
+ * LittleJS Utility Classes and Functions
3
+ * - Timer - tracks time automatically with support for pause and real-time modes
4
+ * - Time formatting helper
5
+ * - JSON file fetching
6
+ * - File saving (text, canvas, data URLs)
7
+ * - Native share dialog support
8
+ * - Local storage save data management
9
+ * - Value noise (1D and 2D)
10
+ * @namespace Utilities
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /**
16
+ * Timer object tracks how long has passed since it was set
17
+ * @memberof Engine
18
+ * @example
19
+ * let a = new Timer; // creates a timer that is not set
20
+ * a.set(3); // sets the timer to 3 seconds
21
+ *
22
+ * let b = new Timer(1); // creates a timer with 1 second left
23
+ * b.unset(); // unset the timer
24
+ */
25
+ class Timer
26
+ {
27
+ /** Create a timer object set time passed in
28
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
29
+ * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
30
+ constructor(timeLeft, useRealTime=false)
31
+ {
32
+ ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Constructed Timer is invalid.', timeLeft);
33
+ this.useRealTime = useRealTime;
34
+ const globalTime = this.getGlobalTime();
35
+ /** @type {number|undefined} */
36
+ this.time = timeLeft === undefined ? undefined : globalTime + timeLeft;
37
+ /** @type {number|undefined} */
38
+ this.setTime = timeLeft;
39
+ }
40
+
41
+ /** Set the timer with seconds passed in
42
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
43
+ set(timeLeft=0)
44
+ {
45
+ ASSERT(isNumber(timeLeft), 'Timer is invalid.', timeLeft);
46
+ const globalTime = this.getGlobalTime();
47
+ this.time = globalTime + timeLeft;
48
+ this.setTime = timeLeft;
49
+ }
50
+
51
+ /** Set if the timer should keep running even when the game is paused
52
+ * @param {boolean} [useRealTime] */
53
+ setUseRealTime(useRealTime=true)
54
+ {
55
+ ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.');
56
+ this.useRealTime = useRealTime;
57
+ }
58
+
59
+ /** Unset the timer */
60
+ unset() { this.time = undefined; }
61
+
62
+ /** Returns true if set
63
+ * @return {boolean} */
64
+ isSet() { return this.time !== undefined; }
65
+
66
+ /** Returns true if set and has not elapsed
67
+ * @return {boolean} */
68
+ active() { return this.getGlobalTime() < this.time; }
69
+
70
+ /** Returns true if set and elapsed
71
+ * @return {boolean} */
72
+ elapsed() { return this.getGlobalTime() >= this.time; }
73
+
74
+ /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
75
+ * @return {number} */
76
+ get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
77
+
78
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
79
+ * Zero-duration timers report 1 (already elapsed).
80
+ * @return {number} */
81
+ getPercent()
82
+ {
83
+ if (!this.isSet()) return 0;
84
+ if (!(this.setTime > 0)) return 1;
85
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
86
+ }
87
+
88
+ /** Get the time this timer was set to, returns 0 if not set
89
+ * @return {number} */
90
+ getSetTime() { return this.isSet() ? this.setTime : 0; }
91
+
92
+ /** Get the current global time this timer is based on
93
+ * @return {number} */
94
+ getGlobalTime() { return this.useRealTime ? timeReal : time; }
95
+
96
+ /** Returns this timer expressed as a string
97
+ * @return {string} */
98
+ toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }
99
+
100
+ /** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
101
+ * @return {number} */
102
+ valueOf() { return this.get(); }
103
+ }
104
+
105
+ ///////////////////////////////////////////////////////////////////////////////
106
+
107
+ /** Formats seconds to mm:ss style for display purposes
108
+ * @param {number} t - time in seconds
109
+ * @return {string}
110
+ * @memberof Utilities */
111
+ function formatTime(t)
112
+ {
113
+ const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00
114
+ t = abs(t)|0;
115
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
116
+ }
117
+
118
+ /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
119
+ * @param {string} url - URL of JSON file
120
+ * @return {Promise<object>}
121
+ * @memberof Utilities */
122
+ async function fetchJSON(url)
123
+ {
124
+ const response = await fetch(url);
125
+ if (!response.ok)
126
+ throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);
127
+ const json = await response.json();
128
+ editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name
129
+ return json;
130
+ }
131
+
132
+ ///////////////////////////////////////////////////////////////////////////////
133
+
134
+ /** Save a text file to disk
135
+ * @param {string} text
136
+ * @param {string} [filename]
137
+ * @param {string} [type]
138
+ * @memberof Utilities */
139
+ function saveText(text, filename='text', type='text/plain')
140
+ { saveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename, 1e3); } // freed once the download has it
141
+
142
+ /** Create an offscreen canvas to draw into, and return its 2D context
143
+ * - The canvas is context.canvas, which is what TextureInfo and the like take
144
+ * @param {number} width - In pixels
145
+ * @param {number} [height] - In pixels, defaults to the width for a square
146
+ * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
147
+ * @return {OffscreenCanvasRenderingContext2D}
148
+ * @memberof Utilities */
149
+ function createCanvasContext(width, height=width, willReadFrequently=false)
150
+ {
151
+ ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
152
+ return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
153
+ }
154
+
155
+ /** Save a canvas to disk
156
+ * @param {HTMLCanvasElement|OffscreenCanvas} canvas
157
+ * @param {string} [filename]
158
+ * @param {string} [type]
159
+ * @memberof Utilities */
160
+ function saveCanvas(canvas, filename='screenshot', type='image/png')
161
+ {
162
+ if (canvas instanceof OffscreenCanvas)
163
+ {
164
+ // copy to temporary canvas and save
165
+ const saveCanvas = document.createElement('canvas');
166
+ saveCanvas.width = canvas.width;
167
+ saveCanvas.height = canvas.height;
168
+ saveCanvas.getContext('2d').drawImage(canvas, 0, 0);
169
+ saveDataURL(saveCanvas.toDataURL(type), filename);
170
+ }
171
+ else
172
+ saveDataURL(canvas.toDataURL(type), filename);
173
+ }
174
+
175
+ /** Save a data url to disk
176
+ * @param {string} url
177
+ * @param {string} [filename]
178
+ * @param {number} [revokeTime] - how long before revoking the url
179
+ * @memberof Utilities */
180
+ function saveDataURL(url, filename='download', revokeTime)
181
+ {
182
+ ASSERT(isStringLike(url), 'saveDataURL requires url string');
183
+ ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
184
+
185
+ // create link for saving screenshots
186
+ const link = document.createElement('a');
187
+ link.download = filename;
188
+ link.href = url;
189
+ link.click();
190
+ if (revokeTime !== undefined)
191
+ setTimeout(()=> URL.revokeObjectURL(url), revokeTime);
192
+ }
193
+
194
+ /** Share content using the native share dialog if available
195
+ * @param {string} title - title of the share
196
+ * @param {string} url - url to share
197
+ * @param {Function} [callback] - Called when share is complete
198
+ * @memberof Utilities */
199
+ function shareURL(title, url, callback)
200
+ {
201
+ ASSERT(isStringLike(title), 'shareURL requires title string');
202
+ ASSERT(isStringLike(url), 'shareURL requires url string');
203
+ navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error
204
+ }
205
+
206
+ ///////////////////////////////////////////////////////////////////////////////
207
+
208
+ /** Read save data from local storage
209
+ * - The result has the type of defaultSaveData, or any when there is none
210
+ * @template {Object<string, any>} [T=any]
211
+ * @param {string} saveName - unique name for the game/save
212
+ * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object
213
+ * @return {T}
214
+ * @memberof Utilities */
215
+ function readSaveData(saveName, defaultSaveData)
216
+ {
217
+ ASSERT(isStringLike(saveName), 'readSaveData requires saveName string');
218
+ ASSERT(defaultSaveData === undefined ||
219
+ (typeof defaultSaveData === 'object' && defaultSaveData !== null),
220
+ 'readSaveData: default must be an object - the result is ' +
221
+ '{...default, ...loaded}, so a scalar default yields {}. ' +
222
+ 'Use readSaveData(key, {best:0}).best');
223
+
224
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
225
+ // iframes) and corrupt JSON in stored data
226
+ let loadedData = {};
227
+ try
228
+ {
229
+ const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member
230
+ if (data)
231
+ {
232
+ try { loadedData = JSON.parse(data); }
233
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
234
+ }
235
+ }
236
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
237
+ return { .../** @type {object} */ (defaultSaveData), ...loadedData };
238
+ }
239
+
240
+ /** Write save data to local storage
241
+ * @param {string} saveName - unique name for the game/save
242
+ * @param {object} saveData - object containing data to be saved
243
+ * @memberof Utilities */
244
+ function writeSaveData(saveName, saveData)
245
+ {
246
+ ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
247
+ // tolerate localStorage being unavailable or quota exceeded
248
+ try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
249
+ catch { LOG('writeSaveData: failed to write', saveName); }
250
+ }
251
+
252
+ ///////////////////////////////////////////////////////////////////////////////
253
+
254
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
255
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
256
+ function noiseHash(i)
257
+ {
258
+ let h = (i | 0) ^ 0x9e3779b9;
259
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
260
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
261
+ h ^= h >>> 16;
262
+ return (h >>> 0) / 2**32;
263
+ }
264
+
265
+ /** 1D value noise — returns a smooth value in [0, 1] for any real x.
266
+ * Integer inputs land on deterministic lattice values; non-integer inputs
267
+ * are interpolated with smoothStep for C1 continuity.
268
+ * @param {number} x
269
+ * @return {number}
270
+ * @memberof Utilities */
271
+ function noise1D(x)
272
+ {
273
+ const i = floor(x);
274
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
275
+ }
276
+
277
+ /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y).
278
+ * @param {number} x
279
+ * @param {number} y
280
+ * @return {number}
281
+ * @memberof Utilities */
282
+ function noise2D(x, y)
283
+ {
284
+ const ix = floor(x), iy = floor(y);
285
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
286
+ // large prime decorrelates neighboring rows
287
+ const h = (a, b) => noiseHash(a + b * 374761393);
288
+ return lerp(
289
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
290
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
291
+ fy);
292
292
  }