littlejsengine 1.20.0 → 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/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.20.0';
35
+ const engineVersion = '1.21.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -40,11 +40,12 @@ const engineVersion = '1.20.0';
40
40
  * @memberof Engine */
41
41
  const frameRate = 60;
42
42
 
43
- /** How many seconds each frame lasts, engine uses a fixed time step
43
+ /** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the
44
+ * display frame it runs on, times timeScale; while paused it keeps the last update's
44
45
  * @type {number}
45
46
  * @default 1/60
46
47
  * @memberof Engine */
47
- const timeDelta = 1/frameRate;
48
+ let timeDelta = 1/frameRate;
48
49
 
49
50
  /** Array containing all engine objects
50
51
  * @type {Array<EngineObject>}
@@ -92,6 +93,75 @@ function setPaused(isPaused=true) { paused = isPaused; }
92
93
 
93
94
  // Engine internal variables
94
95
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
96
+
97
+ // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
98
+ // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
99
+ // length is estimated from recent frames and kept internal, since the browser does not say what it is
100
+ const frameDeltaHistory = [];
101
+ let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true;
102
+ function engineSmoothDelta(deltaMS)
103
+ {
104
+ if (!(deltaMS > 0)) return 0;
105
+ if (deltaMS < 250) // a gap from a hidden tab is not a display frame
106
+ {
107
+ frameDeltaHistory.push(deltaMS);
108
+ frameDeltaHistory.length > 64 && frameDeltaHistory.shift();
109
+
110
+ // the frames each delta held are counted in the last estimate or in the lower quartile of the last 16,
111
+ // whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and
112
+ // it takes over when the window moves to a display with another refresh rate, or the estimate went wrong
113
+ const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b);
114
+ const [lastMS, lastOff] = engineFrameFit(frameIntervalMS);
115
+ const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]);
116
+ frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS;
117
+
118
+ // deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip
119
+ frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12);
120
+ }
121
+
122
+ if (!frameDeltaSmoothing)
123
+ {
124
+ // deltas that are not whole frames of one length, like a variable refresh display, are the frame times
125
+ // as they are, with any carry paid, and smoothing starts over once they fit again
126
+ const rawMS = deltaMS + frameDeltaCarryMS;
127
+ frameDeltaCarryMS = min(rawMS, 0);
128
+ frameDeltaCarryAverageMS = 0;
129
+ return max(rawMS, 0);
130
+ }
131
+
132
+ // whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a
133
+ // little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame
134
+ const pullMS = frameDeltaCarryAverageMS / 32;
135
+ frameDeltaCarryMS += deltaMS;
136
+ const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS);
137
+ if (frames < 1)
138
+ return 0; // the carry keeps it
139
+ const smoothMS = frames * frameIntervalMS + pullMS;
140
+ frameDeltaCarryMS -= smoothMS;
141
+ frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32;
142
+ return smoothMS;
143
+ }
144
+
145
+ // fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over
146
+ // frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames
147
+ function engineFrameFit(unitMS)
148
+ {
149
+ let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0;
150
+ const count = frameDeltaHistory.length;
151
+ for (const d of frameDeltaHistory)
152
+ {
153
+ const held = max(1, round(d / unitMS));
154
+ timeMS += d;
155
+ frames += held;
156
+ offMS += abs(d - held * unitMS);
157
+ sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS;
158
+ }
159
+ const spread = count * sumFF - sumF * sumF;
160
+ return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS];
161
+ }
162
+
163
+ // where the fixed step's time counts from, moved when the variable step hands back so time goes on from there
164
+ let timeFixedStart = 0, frameFixedStart = 0;
95
165
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
96
166
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
97
167
  let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
@@ -109,6 +179,9 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
109
179
  others.set(other, resolve);
110
180
  }
111
181
  let engineInitialized = false; // engineInit ran, with or without a canvas
182
+ // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
183
+ // loop starts
184
+ let engineLoads, engineLoadsDone = 0;
112
185
  let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
113
186
  const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
114
187
  let showEngineVersion = true;
@@ -158,11 +231,68 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
158
231
  ///////////////////////////////////////////////////////////////////////////////
159
232
  // Main Engine Functions
160
233
 
234
+ /** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts
235
+ * once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on
236
+ * their own, and a load that fails counts as done; after startup it does nothing
237
+ * @param {Promise<any>} promise
238
+ * @return {Promise<any>} - The same promise
239
+ * @example
240
+ * async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); }
241
+ * @memberof Engine */
242
+ function engineAddLoad(promise)
243
+ {
244
+ if (engineLoads)
245
+ {
246
+ engineLoads.push(promise);
247
+ const done = ()=> { ++engineLoadsDone; };
248
+ promise.then(done, done);
249
+ }
250
+ return promise;
251
+ }
252
+
253
+ // wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile
254
+ async function engineWaitForLoads()
255
+ {
256
+ let waiting = true;
257
+ const start = performance.now();
258
+ const drawFrame = ()=>
259
+ {
260
+ if (!waiting) return;
261
+ engineLoadingScreenDraw((performance.now() - start) / 1e3);
262
+ setTimeout(drawFrame, 16);
263
+ };
264
+ headlessMode || drawFrame();
265
+ try
266
+ {
267
+ for (let count; count !== engineLoads.length;)
268
+ {
269
+ count = engineLoads.length;
270
+ await Promise.allSettled(engineLoads);
271
+ }
272
+ }
273
+ finally { waiting = false; }
274
+ }
275
+
276
+ // one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input
277
+ // while it shows is dropped, as it is under the splash
278
+ function engineLoadingScreenDraw(elapsed)
279
+ {
280
+ if (headlessMode || !loadingScreen || elapsed < .5) return;
281
+ inputClear();
282
+ engineUpdateCanvas();
283
+ loadingScreen(engineLoadsDone / engineLoads.length);
284
+ }
285
+
161
286
  /**
162
287
  * @callback GameInitCallback - Called after the engine starts, can be async
163
288
  * @return {void|Promise<void>}
164
289
  * @memberof Engine
165
290
  */
291
+ /**
292
+ * @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads
293
+ * @param {number} progress - The part of the loads done, 0 to 1
294
+ * @memberof Engine
295
+ */
166
296
  /**
167
297
  * @callback GameCallback - Update or render function for the game
168
298
  * @memberof Engine
@@ -197,6 +327,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
197
327
  // double-register listeners / double-add canvases on a second call
198
328
  if (engineInitialized) return;
199
329
  engineInitialized = true;
330
+ engineLoads = [], engineLoadsDone = 0;
200
331
  ASSERT(isArray(imageSources), 'pass in images as array');
201
332
 
202
333
  // allow passing in empty functions
@@ -236,6 +367,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
236
367
  frameTimeLastMS = frameTimeMS;
237
368
  if (debug || debugWatermark)
238
369
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
370
+ // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
371
+ if (!manualStepAtStart)
372
+ frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
239
373
  audioUpdateVolume();
240
374
  // the time keys work while the debug overlay is open, or always when debugKeysAlways is set
241
375
  const debugKeys = debug && (debugOverlay || debugKeysAlways);
@@ -248,32 +382,61 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
248
382
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
249
383
  const combinedScale = timeScale * debugScale;
250
384
  frameTimeDeltaMS *= combinedScale;
251
- // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead of
252
- // following however fast the display refreshes, and gameUpdatePost and input still run to leave it
253
- const frozen = paused || !combinedScale;
254
- frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
255
- frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
256
-
257
- // apply time delta smoothing, improves smoothness of framerate in some browsers
258
- let wasUpdated = false, deltaSmooth = 0;
259
- if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
385
+ let wasUpdated = false;
386
+ if (engineVariableStep)
260
387
  {
261
- // force at least one update each frame since it is waiting for refresh
262
- deltaSmooth = frameTimeBufferMS;
263
- frameTimeBufferMS = 0;
388
+ // one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale;
389
+ // engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from
390
+ if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart)
391
+ {
392
+ // frozen stands still as in the fixed step, and timeDelta keeps the last update's
393
+ const frozenTick = paused || !combinedScale;
394
+ if (!frozenTick)
395
+ {
396
+ timeDelta = manualStepAtStart ? combinedScale / frameRate :
397
+ min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate
398
+ time += timeDelta;
399
+ ++frame;
400
+ }
401
+ engineTick(frozenTick);
402
+ }
264
403
  }
265
-
266
- // update multiple frames if necessary in case of slow framerate
267
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
404
+ else
268
405
  {
269
- // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
270
- const frozenTick = paused || !(timeScale * debugScale);
406
+ // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead
407
+ // of following however fast the display refreshes, and gameUpdatePost and input still run to leave it
408
+ const frozen = paused || !combinedScale;
409
+ frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
410
+ frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
411
+
412
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
413
+ let deltaSmooth = 0;
414
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
415
+ {
416
+ // force at least one update each frame since it is waiting for refresh
417
+ deltaSmooth = frameTimeBufferMS;
418
+ frameTimeBufferMS = 0;
419
+ }
271
420
 
272
- // increment frame and update time, frozen does not advance time
273
- if (!frozenTick)
274
- time = frame++ / frameRate;
421
+ // update multiple frames if necessary in case of slow framerate
422
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
423
+ {
424
+ // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
425
+ const frozenTick = paused || !(timeScale * debugScale);
275
426
 
276
- // update game and objects, when frozen update everything except them
427
+ // increment frame and update time, frozen does not advance time
428
+ if (!frozenTick)
429
+ time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
430
+ engineTick(frozenTick);
431
+ }
432
+
433
+ // add the time smoothing back in
434
+ frameTimeBufferMS += deltaSmooth;
435
+ }
436
+
437
+ // one tick of the loop: update game and objects, when frozen update everything except them
438
+ function engineTick(frozenTick)
439
+ {
277
440
  wasUpdated = true;
278
441
  engineUpdateCanvas();
279
442
  inputUpdate();
@@ -300,9 +463,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
300
463
  renderFrame();
301
464
  }
302
465
 
303
- // add the time smoothing back in
304
- frameTimeBufferMS += deltaSmooth;
305
-
306
466
  // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
307
467
  if (engineManualStep && !manualStepAtStart)
308
468
  frameTimeBufferMS = -.5e3 / frameRate;
@@ -361,7 +521,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
361
521
  }
362
522
 
363
523
  // skip setup if headless
364
- if (headlessMode) return startEngine();
524
+ if (headlessMode) return startEngine([]);
365
525
 
366
526
  // ensure body exists for minimal HTML where the script runs before <body> is parsed
367
527
  if (!document.body)
@@ -439,14 +599,19 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
439
599
  promises.push(splash);
440
600
  }
441
601
 
442
- // wait for all the promises to finish
443
- await Promise.all(promises);
444
- return startEngine();
602
+ // the splash first, the images load under it, then the loading screen for the rest
603
+ showSplashScreen && await promises.at(-1);
604
+ return startEngine(promises);
445
605
 
446
- async function startEngine()
606
+ // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
607
+ // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
608
+ async function startEngine(images)
447
609
  {
448
- // wait for gameInit to load
449
- await gameInit();
610
+ const init = (async ()=> { await Promise.all(images); await gameInit(); })();
611
+ engineAddLoad(init);
612
+ await engineWaitForLoads();
613
+ engineLoads = undefined;
614
+ await init;
450
615
  engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
451
616
  engineManualStep || engineUpdate();
452
617
  }
@@ -281,8 +281,8 @@ class Sound
281
281
  // load the audio file, a URL object as bundlers give works like its string;
282
282
  // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
283
283
  const filename = asset + '';
284
- this.loadSound(filename).catch(e=>
285
- LOG('Sound load failed for', filename, '-', e.message));
284
+ engineAddLoad(this.loadSound(filename).catch(e=>
285
+ LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
286
286
  }
287
287
  }
288
288
 
@@ -49,11 +49,13 @@ const enginePluginFiles =
49
49
  `${PLUGIN_FOLDER}/drawUtilities.js`,
50
50
  `${PLUGIN_FOLDER}/textureSheet.js`,
51
51
  `${PLUGIN_FOLDER}/tweenSystem.js`,
52
+ `${PLUGIN_FOLDER}/sceneSystem.js`,
52
53
  `${PLUGIN_FOLDER}/pathFinder.js`,
53
54
  `${PLUGIN_FOLDER}/math3d.js`,
54
55
  `${PLUGIN_FOLDER}/render3d.js`,
55
56
  `${PLUGIN_FOLDER}/render3dExtras.js`,
56
57
  `${PLUGIN_FOLDER}/render3dVoxels.js`,
58
+ `${PLUGIN_FOLDER}/render3dLevel.js`,
57
59
  `${PLUGIN_FOLDER}/gltf.js`,
58
60
  `${PLUGIN_FOLDER}/threejs.js`,
59
61
  ];
@@ -63,6 +65,7 @@ const engineDebugFiles =
63
65
  `${PLUGIN_FOLDER}/tweakables.js`,
64
66
  `${PLUGIN_FOLDER}/render3dDebug.js`,
65
67
  `${SOURCE_FOLDER}/engineEditor.js`,
68
+ `${PLUGIN_FOLDER}/render3dEditor.js`,
66
69
  ];
67
70
  const engineExtraFiles =
68
71
  [
@@ -684,6 +684,14 @@ function debugRender()
684
684
  debugContext.fillText('9: Tweakables', x, y += h);
685
685
  debugContext.fillStyle = levelEditor.isOpen ? '#f00' : '#fff';
686
686
  debugContext.fillText('0: Edit Level', x, y += h);
687
+ for (const line of debugOverlayKeys)
688
+ {
689
+ // a key a debug plugin added, when it has something to say
690
+ const key = line();
691
+ if (!key) continue;
692
+ debugContext.fillStyle = key.on ? '#f00' : '#fff';
693
+ debugContext.fillText(key.text, x, y += h);
694
+ }
687
695
  debugContext.fillStyle = '#fff';
688
696
 
689
697
  let keysPressed = '';
@@ -910,4 +918,50 @@ function debugProtectConstant(obj)
910
918
 
911
919
  // freeze the object to prevent adding new properties
912
920
  return Object.freeze(obj);
913
- }
921
+ }
922
+
923
+ ///////////////////////////////////////////////////////////////////////////////
924
+ // Input capture: the free camera and the 3D level editor take the keyboard and mouse, so the game under them reads
925
+ // every key and mouse button as up, and no mouse movement or wheel; gamepads are left to the game
926
+
927
+ let inputCaptureOn = false; // something has taken the keyboard and mouse
928
+ let inputCaptureReading = false; // it is reading them now
929
+ let inputCaptureDeltaScreen, inputCaptureWheel = 0; // this update's mouse movement and wheel, for it alone
930
+
931
+ // the game let go of a captured mouse itself, with pointerLockExit, since the debug tools last looked: a capture
932
+ // that is lost without it is the browser's doing, which is how Chrome takes Escape
933
+ let inputLockLetGo = false;
934
+ function inputLockExit() { inputLockLetGo = true; }
935
+
936
+ // the keys debug plugins add to the overlay's list, each a function that gives {text, on} or nothing to show
937
+ const debugOverlayKeys = [];
938
+
939
+ // take the keyboard and mouse from the game, or hand them back
940
+ function inputCapture(on=true)
941
+ {
942
+ inputCaptureOn = !!on;
943
+ inputCaptureDeltaScreen = vec2();
944
+ inputCaptureWheel = 0;
945
+ }
946
+
947
+ // read the input as the one that took it, keyIsDown and the rest work as always inside read
948
+ function inputCaptureRead(read)
949
+ {
950
+ const was = inputCaptureReading;
951
+ inputCaptureReading = true;
952
+ try { return read(); }
953
+ finally { inputCaptureReading = was; }
954
+ }
955
+
956
+ // if a read of the keyboard and mouse, device 0, is the game's while they are taken
957
+ function inputCaptureHides(device) { return inputCaptureOn && !inputCaptureReading && !device; }
958
+
959
+ // called first in inputUpdate: the mouse movement and wheel go to the one that took the input
960
+ function inputCaptureMouse()
961
+ {
962
+ if (!inputCaptureOn) return;
963
+ inputCaptureDeltaScreen = mouseDeltaScreen;
964
+ inputCaptureWheel = mouseWheel;
965
+ mouseDeltaScreen = vec2();
966
+ mouseWheel = 0;
967
+ }
package/src/engineDraw.js CHANGED
@@ -308,12 +308,20 @@ class TextureInfo
308
308
  }
309
309
 
310
310
  ///////////////////////////////////////////////////////////////////////////////
311
+ /**
312
+ * @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends
313
+ * @return {void}
314
+ * @memberof Draw
315
+ */
316
+
311
317
  /**
312
318
  * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
313
319
  * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
314
320
  * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
315
321
  * - loop, play and pingPong each start over from the first frame; stop holds the current one
322
+ * - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it
316
323
  * - Frames follow each other along the row, as tileInfo.frame counts them
324
+ * - SpriteAnimator switches between a character's animations by name
317
325
  * @example
318
326
  * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
319
327
  * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
@@ -345,6 +353,9 @@ class SpriteAnimation
345
353
  /** @property {number|undefined} - The frame held by stop, undefined while running
346
354
  * @type {number|undefined} */
347
355
  this.heldFrame = undefined;
356
+ /** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it
357
+ * @type {SpriteAnimationEndCallback|undefined} */
358
+ this.onEnd = undefined;
348
359
  }
349
360
 
350
361
  /** Start over from the first frame and repeat forever
@@ -352,8 +363,14 @@ class SpriteAnimation
352
363
  loop() { return this.restart('loop'); }
353
364
 
354
365
  /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
366
+ * @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that
355
367
  * @return {SpriteAnimation} */
356
- play() { return this.restart('once'); }
368
+ play(onEnd)
369
+ {
370
+ this.restart('once');
371
+ this.onEnd = onEnd;
372
+ return this;
373
+ }
357
374
 
358
375
  /** Start over from the first frame and run there and back forever
359
376
  * @return {SpriteAnimation} */
@@ -363,7 +380,7 @@ class SpriteAnimation
363
380
  * @return {SpriteAnimation} */
364
381
  stop() { this.heldFrame = this.frame; return this; }
365
382
 
366
- /** Start over from the first frame in a mode
383
+ /** Start over from the first frame in a mode, with no end callback
367
384
  * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
368
385
  * @return {SpriteAnimation} */
369
386
  restart(mode=this.mode)
@@ -371,6 +388,7 @@ class SpriteAnimation
371
388
  this.mode = mode;
372
389
  this.startTime = time;
373
390
  this.heldFrame = undefined;
391
+ this.onEnd = undefined;
374
392
  return this;
375
393
  }
376
394
 
@@ -382,6 +400,7 @@ class SpriteAnimation
382
400
  * @return {number} */
383
401
  get frame()
384
402
  {
403
+ this.isDone; // a read after a play ends calls onEnd, which may start it over
385
404
  if (this.heldFrame !== undefined)
386
405
  return this.heldFrame;
387
406
  const n = this.frameCount, f = floor(this.elapsedFrames);
@@ -397,9 +416,81 @@ class SpriteAnimation
397
416
  * @return {TileInfo} */
398
417
  get tileInfo() { return this.firstTile.frame(this.frame); }
399
418
 
400
- /** True once a play has shown its last frame for its time
419
+ /** True once a play has shown its last frame for its time, the first read after that calls onEnd
401
420
  * @return {boolean} */
402
- get isDone() { return this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; }
421
+ get isDone()
422
+ {
423
+ const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount;
424
+ if (done && this.onEnd)
425
+ {
426
+ const onEnd = this.onEnd;
427
+ this.onEnd = undefined; // once, and it can start a new play with a callback of its own
428
+ onEnd();
429
+ }
430
+ return done;
431
+ }
432
+ }
433
+
434
+ ///////////////////////////////////////////////////////////////////////////////
435
+ /**
436
+ * SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now
437
+ * - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong
438
+ * - set starts a clip over only when it changes or a play of it has ended, so it can be called every update
439
+ * - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation
440
+ * @example
441
+ * const hero = new SpriteAnimator({
442
+ * idle: new SpriteAnimation(tile(0, 16), 2, .4),
443
+ * walk: new SpriteAnimation(tile(2, 16), 4, .1),
444
+ * attack: new SpriteAnimation(tile(6, 16), 3, .05).play(),
445
+ * });
446
+ * hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends
447
+ * // in update: this.tileInfo = hero.tileInfo;
448
+ * @memberof Draw
449
+ */
450
+ class SpriteAnimator
451
+ {
452
+ /** Create an animator from its clips, showing the first one
453
+ * @param {Object<string, SpriteAnimation>} clips - The clips by name */
454
+ constructor(clips)
455
+ {
456
+ const names = Object.keys(clips);
457
+ ASSERT(names.length > 0, 'an animator needs at least one clip');
458
+ /** @property {Object<string, SpriteAnimation>} - The clips by name */
459
+ this.clips = clips;
460
+ /** @property {string} - The name of the clip showing now */
461
+ this.name = names[0];
462
+ }
463
+
464
+ /** Show a clip, starting it over in its own mode unless it is showing and has not ended
465
+ * @param {string} name - The clip's name
466
+ * @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play
467
+ * @return {SpriteAnimator} */
468
+ set(name, onEnd)
469
+ {
470
+ ASSERT(this.clips[name], 'no clip named ' + name);
471
+ if (name == this.name && !this.clip.isDone)
472
+ return this;
473
+ this.name = name;
474
+ this.clip.restart();
475
+ this.clip.onEnd = onEnd;
476
+ return this;
477
+ }
478
+
479
+ /** The clip showing now
480
+ * @return {SpriteAnimation} */
481
+ get clip() { return this.clips[this.name]; }
482
+
483
+ /** The tile of the frame showing now
484
+ * @return {TileInfo} */
485
+ get tileInfo() { return this.clip.tileInfo; }
486
+
487
+ /** The frame of the clip showing now
488
+ * @return {number} */
489
+ get frame() { return this.clip.frame; }
490
+
491
+ /** True once the clip showing now is a play that has ended
492
+ * @return {boolean} */
493
+ get isDone() { return this.clip.isDone; }
403
494
  }
404
495
 
405
496
  ///////////////////////////////////////////////////////////////////////////////
@@ -1224,7 +1315,7 @@ async function loadTexture(textureIndex, src)
1224
1315
  const image = new Image;
1225
1316
  if (src)
1226
1317
  {
1227
- await new Promise(resolve =>
1318
+ await engineAddLoad(new Promise(resolve => // startup waits for it
1228
1319
  {
1229
1320
  image.onload = resolve;
1230
1321
  image.onerror = ()=>
@@ -1234,7 +1325,7 @@ async function loadTexture(textureIndex, src)
1234
1325
  };
1235
1326
  image.crossOrigin = 'anonymous';
1236
1327
  image.src = src;
1237
- });
1328
+ }));
1238
1329
  }
1239
1330
 
1240
1331
  return textureInfos[textureIndex] = new TextureInfo(image);