littlejsengine 1.18.25 → 1.18.26
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/dist/littlejs.esm.js +619 -613
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +619 -613
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +619 -613
- package/package.json +58 -58
- package/src/engine.js +612 -612
- package/src/engineDraw.js +9 -3
package/dist/littlejs.esm.js
CHANGED
|
@@ -3,616 +3,616 @@
|
|
|
3
3
|
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* LittleJS - The Tiny Fast JavaScript Game Engine
|
|
8
|
-
* MIT License - Copyright 2021 Frank Force
|
|
9
|
-
*
|
|
10
|
-
* Engine Features
|
|
11
|
-
* - Object oriented system with EngineObject base class
|
|
12
|
-
* - Automatic object lifecycle (update, physics, collision, rendering)
|
|
13
|
-
* - Engine helper classes: Vector2, Color, Timer, RandomGenerator
|
|
14
|
-
* - Hybrid rendering with WebGL batching and Canvas2D fallback
|
|
15
|
-
* - Audio system with wave, mp3, or ZzFX sound effects
|
|
16
|
-
* - Input system with keyboard, mouse, gamepad, and touch support
|
|
17
|
-
* - Tile layer rendering and collision detection
|
|
18
|
-
* - Particle effect system with emitters
|
|
19
|
-
* - Medal/achievement system with local storage
|
|
20
|
-
* - Comprehensive debug tools and visualizations
|
|
21
|
-
* - Fixed 60 FPS timestep with configurable time scale
|
|
22
|
-
* - Raycast and spatial query utilities
|
|
23
|
-
* - Plugin system for extending engine functionality
|
|
24
|
-
* - Start with engineInit() and provide your game callbacks
|
|
25
|
-
* @namespace Engine
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** Name of engine
|
|
29
|
-
* @type {string}
|
|
30
|
-
* @default
|
|
31
|
-
* @memberof Engine */
|
|
32
|
-
const engineName = 'LittleJS';
|
|
33
|
-
|
|
34
|
-
/** Version of engine
|
|
35
|
-
* @type {string}
|
|
36
|
-
* @default
|
|
37
|
-
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
39
|
-
|
|
40
|
-
/** Frames per second to update
|
|
41
|
-
* @type {number}
|
|
42
|
-
* @default
|
|
43
|
-
* @memberof Engine */
|
|
44
|
-
const frameRate = 60;
|
|
45
|
-
|
|
46
|
-
/** How many seconds each frame lasts, engine uses a fixed time step
|
|
47
|
-
* @type {number}
|
|
48
|
-
* @default 1/60
|
|
49
|
-
* @memberof Engine */
|
|
50
|
-
const timeDelta = 1/frameRate;
|
|
51
|
-
|
|
52
|
-
/** Array containing all engine objects
|
|
53
|
-
* @type {Array<EngineObject>}
|
|
54
|
-
* @memberof Engine */
|
|
55
|
-
let engineObjects = [];
|
|
56
|
-
|
|
57
|
-
/** Array with only objects set to collide with other objects this frame (for optimization)
|
|
58
|
-
* @type {Array<EngineObject>}
|
|
59
|
-
* @memberof Engine */
|
|
60
|
-
let engineObjectsCollide = [];
|
|
61
|
-
|
|
62
|
-
/** Current update frame, used to calculate time
|
|
63
|
-
* @type {number}
|
|
64
|
-
* @memberof Engine */
|
|
65
|
-
let frame = 0;
|
|
66
|
-
|
|
67
|
-
/** Current engine time since start in seconds
|
|
68
|
-
* @type {number}
|
|
69
|
-
* @memberof Engine */
|
|
70
|
-
let time = 0;
|
|
71
|
-
|
|
72
|
-
/** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
|
|
73
|
-
* @type {number}
|
|
74
|
-
* @memberof Engine */
|
|
75
|
-
let timeReal = 0;
|
|
76
|
-
|
|
77
|
-
/** Is the game paused? Causes time and objects to not be updated
|
|
78
|
-
* @type {boolean}
|
|
79
|
-
* @default false
|
|
80
|
-
* @memberof Engine */
|
|
81
|
-
let paused = false;
|
|
82
|
-
|
|
83
|
-
/** Get if game is paused
|
|
84
|
-
* @return {boolean}
|
|
85
|
-
* @memberof Engine */
|
|
86
|
-
function getPaused() { return paused; }
|
|
87
|
-
|
|
88
|
-
/** Set if game is paused
|
|
89
|
-
* @param {boolean} [isPaused]
|
|
90
|
-
* @memberof Engine */
|
|
91
|
-
function setPaused(isPaused=true) { paused = isPaused; }
|
|
92
|
-
|
|
93
|
-
// Engine internal variables
|
|
94
|
-
let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
|
|
95
|
-
let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
|
|
96
|
-
let showEngineVersion = true;
|
|
97
|
-
|
|
98
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
99
|
-
// plugin hooks
|
|
100
|
-
|
|
101
|
-
const pluginList = [];
|
|
102
|
-
class EnginePlugin
|
|
103
|
-
{
|
|
104
|
-
constructor(update, render, glContextLost, glContextRestored)
|
|
105
|
-
{
|
|
106
|
-
this.update = update;
|
|
107
|
-
this.render = render;
|
|
108
|
-
this.glContextLost = glContextLost;
|
|
109
|
-
this.glContextRestored = glContextRestored;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* @callback PluginCallback - Update or render function for a plugin
|
|
115
|
-
* @memberof Engine
|
|
116
|
-
*/
|
|
117
|
-
|
|
118
|
-
/** Add a new update function for a plugin
|
|
119
|
-
* @param {PluginCallback} [update]
|
|
120
|
-
* @param {PluginCallback} [render]
|
|
121
|
-
* @param {PluginCallback} [glContextLost]
|
|
122
|
-
* @param {PluginCallback} [glContextRestored]
|
|
123
|
-
* @memberof Engine */
|
|
124
|
-
function engineAddPlugin(update, render, glContextLost, glContextRestored)
|
|
125
|
-
{
|
|
126
|
-
// make sure plugin functions are unique
|
|
127
|
-
ASSERT(!pluginList.find(p=>
|
|
128
|
-
p.update === update && p.render === render &&
|
|
129
|
-
p.glContextLost === glContextLost &&
|
|
130
|
-
p.glContextRestored === glContextRestored));
|
|
131
|
-
|
|
132
|
-
const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
|
|
133
|
-
pluginList.push(plugin);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
137
|
-
// Main Engine Functions
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* @callback GameInitCallback - Called after the engine starts, can be async
|
|
141
|
-
* @return {void|Promise<void>}
|
|
142
|
-
* @memberof Engine
|
|
143
|
-
*/
|
|
144
|
-
/**
|
|
145
|
-
* @callback GameCallback - Update or render function for the game
|
|
146
|
-
* @memberof Engine
|
|
147
|
-
*/
|
|
148
|
-
|
|
149
|
-
/** Startup LittleJS engine with your callback functions
|
|
150
|
-
* @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
|
|
151
|
-
* @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
|
|
152
|
-
* @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
|
|
153
|
-
* @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
|
|
154
|
-
* @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
|
|
155
|
-
* @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
|
|
156
|
-
* @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
|
|
157
|
-
* @example
|
|
158
|
-
* // Basic engine startup
|
|
159
|
-
* engineInit(
|
|
160
|
-
* ()=> { LOG('Game initialized!'); }, // gameInit
|
|
161
|
-
* ()=> { updateGameLogic(); }, // gameUpdate
|
|
162
|
-
* ()=> { updateUI(); }, // gameUpdatePost
|
|
163
|
-
* ()=> { drawBackground(); }, // gameRender
|
|
164
|
-
* ()=> { drawHUD(); }, // gameRenderPost
|
|
165
|
-
* ['tiles.png', 'tilesLevel.png'] // images to load
|
|
166
|
-
* );
|
|
167
|
-
* @memberof Engine */
|
|
168
|
-
async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
|
|
169
|
-
{
|
|
170
|
-
showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
|
|
171
|
-
ASSERT(!mainContext, 'engine already initialized');
|
|
172
|
-
// runtime guard so release builds (where the assert is stripped) don't
|
|
173
|
-
// double-register listeners / double-add canvases on a second call
|
|
174
|
-
if (mainContext) return;
|
|
175
|
-
ASSERT(isArray(imageSources), 'pass in images as array');
|
|
176
|
-
|
|
177
|
-
// ensure body exists for minimal HTML where the script runs before <body> is parsed
|
|
178
|
-
if (!document.body)
|
|
179
|
-
document.documentElement.appendChild(document.createElement('body'));
|
|
180
|
-
rootElement ||= document.body;
|
|
181
|
-
|
|
182
|
-
// allow passing in empty functions
|
|
183
|
-
gameInit ||= ()=>{};
|
|
184
|
-
gameUpdate ||= ()=>{};
|
|
185
|
-
gameUpdatePost ||= ()=>{};
|
|
186
|
-
gameRender ||= ()=>{};
|
|
187
|
-
gameRenderPost ||= ()=>{};
|
|
188
|
-
|
|
189
|
-
// Called automatically by engine to setup render system
|
|
190
|
-
function enginePreRender()
|
|
191
|
-
{
|
|
192
|
-
// save canvas size
|
|
193
|
-
mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
|
|
194
|
-
|
|
195
|
-
// disable smoothing for pixel art
|
|
196
|
-
mainContext.imageSmoothingEnabled = !tilesPixelated;
|
|
197
|
-
|
|
198
|
-
// setup gl rendering if enabled
|
|
199
|
-
glPreRender();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// internal update loop for engine
|
|
203
|
-
function engineUpdate(frameTimeMS=0)
|
|
204
|
-
{
|
|
205
|
-
// update time keeping
|
|
206
|
-
let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
|
|
207
|
-
// skip delta on the very first frame so timeReal doesn't jump
|
|
208
|
-
// by ~page-load-time when RAF starts handing real timestamps
|
|
209
|
-
if (!frameTimeLastMS) frameTimeDeltaMS = 0;
|
|
210
|
-
frameTimeLastMS = frameTimeMS;
|
|
211
|
-
if (debug || debugWatermark)
|
|
212
|
-
averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
|
|
213
|
-
const debugSpeedUp = debug && keyIsDown('Equal'); // +
|
|
214
|
-
const debugSpeedDown = debug && keyIsDown('Minus'); // -
|
|
215
|
-
const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
|
|
216
|
-
|
|
217
|
-
// apply time deltas
|
|
218
|
-
timeReal += frameTimeDeltaMS * debugScale / 1e3;
|
|
219
|
-
const combinedScale = timeScale * debugScale;
|
|
220
|
-
frameTimeDeltaMS *= combinedScale;
|
|
221
|
-
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
|
|
222
|
-
if (combinedScale <= 1)
|
|
223
|
-
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
|
|
224
|
-
|
|
225
|
-
let wasUpdated = false;
|
|
226
|
-
if (paused)
|
|
227
|
-
{
|
|
228
|
-
// update everything except the game and objects
|
|
229
|
-
wasUpdated = true;
|
|
230
|
-
updateCanvas();
|
|
231
|
-
inputUpdate();
|
|
232
|
-
pluginList.forEach(plugin=>plugin.update?.());
|
|
233
|
-
|
|
234
|
-
// update object transforms even when paused
|
|
235
|
-
for (const o of engineObjects)
|
|
236
|
-
o.parent || o.updateTransforms();
|
|
237
|
-
|
|
238
|
-
// do post update
|
|
239
|
-
debugUpdate();
|
|
240
|
-
gameUpdatePost();
|
|
241
|
-
inputUpdatePost();
|
|
242
|
-
if (debugVideoCaptureIsActive())
|
|
243
|
-
renderFrame();
|
|
244
|
-
}
|
|
245
|
-
else
|
|
246
|
-
{
|
|
247
|
-
// apply time delta smoothing, improves smoothness of framerate in some browsers
|
|
248
|
-
let deltaSmooth = 0;
|
|
249
|
-
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
|
|
250
|
-
{
|
|
251
|
-
// force at least one update each frame since it is waiting for refresh
|
|
252
|
-
deltaSmooth = frameTimeBufferMS;
|
|
253
|
-
frameTimeBufferMS = 0;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// update multiple frames if necessary in case of slow framerate
|
|
257
|
-
for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
|
|
258
|
-
{
|
|
259
|
-
// increment frame and update time
|
|
260
|
-
time = frame++ / frameRate;
|
|
261
|
-
|
|
262
|
-
// update game and objects
|
|
263
|
-
wasUpdated = true;
|
|
264
|
-
updateCanvas();
|
|
265
|
-
inputUpdate();
|
|
266
|
-
gameUpdate();
|
|
267
|
-
pluginList.forEach(plugin=>plugin.update?.());
|
|
268
|
-
engineObjectsUpdate();
|
|
269
|
-
|
|
270
|
-
// do post update
|
|
271
|
-
debugUpdate();
|
|
272
|
-
gameUpdatePost();
|
|
273
|
-
inputUpdatePost();
|
|
274
|
-
if (debugVideoCaptureIsActive())
|
|
275
|
-
renderFrame();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// add the time smoothing back in
|
|
279
|
-
frameTimeBufferMS += deltaSmooth;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (!debugVideoCaptureIsActive())
|
|
283
|
-
renderFrame();
|
|
284
|
-
if (!engineManualStep)
|
|
285
|
-
requestAnimationFrame(engineUpdate);
|
|
286
|
-
|
|
287
|
-
function renderFrame()
|
|
288
|
-
{
|
|
289
|
-
if (headlessMode) return;
|
|
290
|
-
|
|
291
|
-
// canvas must be updated before rendering
|
|
292
|
-
if (!wasUpdated)
|
|
293
|
-
updateCanvas();
|
|
294
|
-
|
|
295
|
-
// render the game and objects
|
|
296
|
-
enginePreRender();
|
|
297
|
-
gameRender();
|
|
298
|
-
engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
|
|
299
|
-
for (const o of engineObjects)
|
|
300
|
-
o.destroyed || o.render();
|
|
301
|
-
|
|
302
|
-
// post rendering
|
|
303
|
-
gameRenderPost();
|
|
304
|
-
pluginList.forEach(plugin=>plugin.render?.());
|
|
305
|
-
inputRender();
|
|
306
|
-
debugRender();
|
|
307
|
-
glFlush();
|
|
308
|
-
debugRenderPost();
|
|
309
|
-
drawCount = 0;
|
|
310
|
-
primitiveCount = 0;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
engineUpdateInternal = engineUpdate;
|
|
314
|
-
|
|
315
|
-
function updateCanvas()
|
|
316
|
-
{
|
|
317
|
-
if (headlessMode) return;
|
|
318
|
-
|
|
319
|
-
if (canvasFixedSize.x)
|
|
320
|
-
{
|
|
321
|
-
// set canvas fixed size
|
|
322
|
-
mainCanvasSize = canvasFixedSize.copy();
|
|
323
|
-
|
|
324
|
-
// fit to window using css width and height
|
|
325
|
-
const innerAspect = innerWidth / innerHeight;
|
|
326
|
-
const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
|
|
327
|
-
const w = innerAspect < fixedAspect ? '100%' : '';
|
|
328
|
-
const h = innerAspect < fixedAspect ? '' : '100%';
|
|
329
|
-
mainCanvas.style.width = w;
|
|
330
|
-
mainCanvas.style.height = h;
|
|
331
|
-
if (glCanvas)
|
|
332
|
-
{
|
|
333
|
-
glCanvas.style.width = w;
|
|
334
|
-
glCanvas.style.height = h;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
else
|
|
338
|
-
{
|
|
339
|
-
// apply device pixel ratio for crisp rendering
|
|
340
|
-
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
341
|
-
const viewWidth = innerWidth * dpr | 0;
|
|
342
|
-
const viewHeight = innerHeight * dpr | 0;
|
|
343
|
-
|
|
344
|
-
// get main canvas size based on window size
|
|
345
|
-
mainCanvasSize.x = min(viewWidth, canvasMaxSize.x);
|
|
346
|
-
mainCanvasSize.y = min(viewHeight, canvasMaxSize.y);
|
|
347
|
-
|
|
348
|
-
// responsive aspect ratio with native resolution
|
|
349
|
-
const innerAspect = viewWidth / viewHeight;
|
|
350
|
-
ASSERT(canvasMinAspect <= canvasMaxAspect);
|
|
351
|
-
if (canvasMaxAspect && innerAspect > canvasMaxAspect)
|
|
352
|
-
{
|
|
353
|
-
// full height
|
|
354
|
-
const w = mainCanvasSize.y * canvasMaxAspect | 0;
|
|
355
|
-
mainCanvasSize.x = min(w, canvasMaxSize.x);
|
|
356
|
-
}
|
|
357
|
-
else if (innerAspect < canvasMinAspect)
|
|
358
|
-
{
|
|
359
|
-
// full width
|
|
360
|
-
const h = mainCanvasSize.x / canvasMinAspect | 0;
|
|
361
|
-
mainCanvasSize.y = min(h, canvasMaxSize.y);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// set CSS display size so backing store renders at viewport size
|
|
365
|
-
const cssW = (mainCanvasSize.x / dpr | 0) + 'px';
|
|
366
|
-
const cssH = (mainCanvasSize.y / dpr | 0) + 'px';
|
|
367
|
-
mainCanvas.style.width = cssW;
|
|
368
|
-
mainCanvas.style.height = cssH;
|
|
369
|
-
if (glCanvas)
|
|
370
|
-
{
|
|
371
|
-
glCanvas.style.width = cssW;
|
|
372
|
-
glCanvas.style.height = cssH;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// clear main canvas and set size
|
|
377
|
-
mainCanvas.width = mainCanvasSize.x;
|
|
378
|
-
mainCanvas.height = mainCanvasSize.y;
|
|
379
|
-
|
|
380
|
-
// apply the clear color to main canvas
|
|
381
|
-
if (canvasClearColor.a > 0 && !glEnable)
|
|
382
|
-
{
|
|
383
|
-
mainContext.fillStyle = canvasClearColor.toString();
|
|
384
|
-
mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
385
|
-
mainContext.fillStyle = BLACK.toString();
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// set default line join and cap
|
|
389
|
-
mainContext.lineJoin = 'round';
|
|
390
|
-
mainContext.lineCap = 'round';
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// skip setup if headless
|
|
394
|
-
if (headlessMode) return startEngine();
|
|
395
|
-
|
|
396
|
-
// setup webgl
|
|
397
|
-
glInit(rootElement);
|
|
398
|
-
|
|
399
|
-
// setup html
|
|
400
|
-
const styleRoot =
|
|
401
|
-
'margin:0;' + // fill the window
|
|
402
|
-
'overflow:hidden;' + // no scroll bars
|
|
403
|
-
'background:#000;' + // set background color
|
|
404
|
-
'user-select:none;' + // prevent hold to select
|
|
405
|
-
'-webkit-user-select:none;' + // compatibility for ios
|
|
406
|
-
'touch-action:none;' + // prevent mobile pinch to resize
|
|
407
|
-
'-webkit-touch-callout:none'; // compatibility for ios
|
|
408
|
-
rootElement.style.cssText = styleRoot;
|
|
409
|
-
mainCanvas = rootElement.appendChild(document.createElement('canvas'));
|
|
410
|
-
drawContext = mainContext = mainCanvas.getContext('2d');
|
|
411
|
-
|
|
412
|
-
// init stuff and start engine
|
|
413
|
-
inputInit();
|
|
414
|
-
audioInit();
|
|
415
|
-
debugInit();
|
|
416
|
-
|
|
417
|
-
// setup canvases
|
|
418
|
-
// transform way is still more reliable than flexbox or grid
|
|
419
|
-
const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
|
|
420
|
-
'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
|
|
421
|
-
mainCanvas.style.cssText = styleCanvas;
|
|
422
|
-
if (glCanvas)
|
|
423
|
-
glCanvas.style.cssText = styleCanvas;
|
|
424
|
-
setCanvasPixelated(canvasPixelated);
|
|
425
|
-
updateCanvas();
|
|
426
|
-
glPreRender();
|
|
427
|
-
|
|
428
|
-
// create offscreen canvases for image processing
|
|
429
|
-
workCanvas = new OffscreenCanvas(64, 64);
|
|
430
|
-
workContext = workCanvas.getContext('2d');
|
|
431
|
-
workReadCanvas = new OffscreenCanvas(64, 64);
|
|
432
|
-
workReadContext = workReadCanvas.getContext('2d', { willReadFrequently: true });
|
|
433
|
-
|
|
434
|
-
// create promises for loading images
|
|
435
|
-
const promises = imageSources.map((src, i)=> loadTexture(i, src));
|
|
436
|
-
|
|
437
|
-
// no images to load
|
|
438
|
-
if (!imageSources.length)
|
|
439
|
-
promises.push(loadTexture(0));
|
|
440
|
-
|
|
441
|
-
// load engine font image
|
|
442
|
-
promises.push(imageFontInit());
|
|
443
|
-
|
|
444
|
-
if (showSplashScreen)
|
|
445
|
-
{
|
|
446
|
-
// draw splash screen
|
|
447
|
-
promises.push(new Promise(resolve =>
|
|
448
|
-
{
|
|
449
|
-
let t = 0;
|
|
450
|
-
updateSplash();
|
|
451
|
-
function updateSplash()
|
|
452
|
-
{
|
|
453
|
-
inputClear();
|
|
454
|
-
drawEngineLogo(t+=.01);
|
|
455
|
-
t>1 ? resolve() : setTimeout(updateSplash, 16);
|
|
456
|
-
}
|
|
457
|
-
}));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// wait for all the promises to finish
|
|
461
|
-
await Promise.all(promises);
|
|
462
|
-
return startEngine();
|
|
463
|
-
|
|
464
|
-
async function startEngine()
|
|
465
|
-
{
|
|
466
|
-
// wait for gameInit to load
|
|
467
|
-
await gameInit();
|
|
468
|
-
engineManualStep || engineUpdate();
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// max frames engineStep can advance in one call, 10 minutes at 60fps
|
|
473
|
-
// large counts block until they finish, so this catches runaway values
|
|
474
|
-
const engineStepMaxFrames = 36000;
|
|
475
|
-
|
|
476
|
-
/** Advance the engine by a number of frames
|
|
477
|
-
* Requires setEngineManualStep(true) before engineInit
|
|
478
|
-
* Respects paused exactly as the normal update loop does
|
|
479
|
-
* @param {number} [frames] - number of engine update ticks, max 36000, each running one fixed update at timeScale 1
|
|
480
|
-
* @example
|
|
481
|
-
* setHeadlessMode(true);
|
|
482
|
-
* setEngineManualStep(true);
|
|
483
|
-
* await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
|
|
484
|
-
* engineStep(600); // advance 10 seconds of game time
|
|
485
|
-
* @memberof Engine */
|
|
486
|
-
function engineStep(frames=1)
|
|
487
|
-
{
|
|
488
|
-
ASSERT(engineManualStep,
|
|
489
|
-
'engineStep requires setEngineManualStep(true) before engineInit');
|
|
490
|
-
ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
|
|
491
|
-
// runtime guard so release builds (where the asserts are stripped) can't
|
|
492
|
-
// start a second requestAnimationFrame chain or call an undefined update
|
|
493
|
-
if (!engineManualStep || !engineUpdateInternal) return;
|
|
494
|
-
ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
|
|
495
|
-
'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
|
|
496
|
-
frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
|
|
497
|
-
for (let i = frames; i > 0; --i)
|
|
498
|
-
engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** Update each engine object, remove destroyed objects, and update time
|
|
502
|
-
* can be called manually if objects need to be updated outside of main loop
|
|
503
|
-
* @memberof Engine */
|
|
504
|
-
function engineObjectsUpdate()
|
|
505
|
-
{
|
|
506
|
-
// get list of solid objects for physics optimization
|
|
507
|
-
engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
|
|
508
|
-
|
|
509
|
-
// update physics before object update
|
|
510
|
-
for (const o of engineObjects)
|
|
511
|
-
if (!o.parent && !o.destroyed)
|
|
512
|
-
o.updatePhysics();
|
|
513
|
-
|
|
514
|
-
// recursive object update
|
|
515
|
-
function updateChildObject(o)
|
|
516
|
-
{
|
|
517
|
-
if (o.destroyed) return;
|
|
518
|
-
|
|
519
|
-
o.update();
|
|
520
|
-
for (const child of o.children)
|
|
521
|
-
updateChildObject(child);
|
|
522
|
-
}
|
|
523
|
-
for (const o of engineObjects)
|
|
524
|
-
{
|
|
525
|
-
if (o.parent || o.destroyed) continue;
|
|
526
|
-
|
|
527
|
-
// update top level objects
|
|
528
|
-
o.update();
|
|
529
|
-
for (const child of o.children)
|
|
530
|
-
updateChildObject(child);
|
|
531
|
-
o.updateTransforms();
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// remove destroyed objects
|
|
535
|
-
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** Destroy and remove all objects
|
|
539
|
-
* - This can be used to clear out all objects when restarting a level
|
|
540
|
-
* - Objects can override their destroy function to do cleanup or stick around
|
|
541
|
-
* @param {boolean} [immediate] - should attached effects be allowed to die off?
|
|
542
|
-
* @memberof Engine */
|
|
543
|
-
function engineObjectsDestroy(immediate=true)
|
|
544
|
-
{
|
|
545
|
-
for (const o of engineObjects)
|
|
546
|
-
o.parent || o.destroy(immediate);
|
|
547
|
-
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/** Collects all object within a given area
|
|
551
|
-
* @param {Vector2} [pos] - Center of test area, or undefined for all objects
|
|
552
|
-
* @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
|
|
553
|
-
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
554
|
-
* @return {Array<EngineObject>} - List of collected objects
|
|
555
|
-
* @memberof Engine */
|
|
556
|
-
function engineObjectsCollect(pos, size, objects=engineObjects)
|
|
557
|
-
{
|
|
558
|
-
const collectedObjects = [];
|
|
559
|
-
if (!pos)
|
|
560
|
-
{
|
|
561
|
-
// all objects
|
|
562
|
-
for (const o of objects)
|
|
563
|
-
collectedObjects.push(o);
|
|
564
|
-
}
|
|
565
|
-
else if (size instanceof Vector2)
|
|
566
|
-
{
|
|
567
|
-
// bounding box test
|
|
568
|
-
for (const o of objects)
|
|
569
|
-
o.isOverlapping(pos, size) && collectedObjects.push(o);
|
|
570
|
-
}
|
|
571
|
-
else
|
|
572
|
-
{
|
|
573
|
-
// circle test
|
|
574
|
-
const sizeSquared = size*size;
|
|
575
|
-
for (const o of objects)
|
|
576
|
-
pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
|
|
577
|
-
}
|
|
578
|
-
return collectedObjects;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* @callback ObjectCallbackFunction - Function that processes an object
|
|
583
|
-
* @param {EngineObject} object
|
|
584
|
-
* @memberof Engine
|
|
585
|
-
*/
|
|
586
|
-
|
|
587
|
-
/** Triggers a callback for each object within a given area
|
|
588
|
-
* @param {Vector2} [pos] - Center of test area, or undefined for all objects
|
|
589
|
-
* @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
|
|
590
|
-
* @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
|
|
591
|
-
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
592
|
-
* @memberof Engine */
|
|
593
|
-
function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
|
|
594
|
-
{ engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
|
|
595
|
-
|
|
596
|
-
/** Return a list of objects intersecting a ray
|
|
597
|
-
* @param {Vector2} start
|
|
598
|
-
* @param {Vector2} end
|
|
599
|
-
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
600
|
-
* @return {Array<EngineObject>} - List of objects hit
|
|
601
|
-
* @memberof Engine */
|
|
602
|
-
function engineObjectsRaycast(start, end, objects=engineObjects)
|
|
603
|
-
{
|
|
604
|
-
const hitObjects = [];
|
|
605
|
-
for (const o of objects)
|
|
606
|
-
{
|
|
607
|
-
if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
|
|
608
|
-
{
|
|
609
|
-
debugRaycast && debugRect(o.pos, o.size, '#f00');
|
|
610
|
-
hitObjects.push(o);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
|
|
615
|
-
return hitObjects;
|
|
6
|
+
/**
|
|
7
|
+
* LittleJS - The Tiny Fast JavaScript Game Engine
|
|
8
|
+
* MIT License - Copyright 2021 Frank Force
|
|
9
|
+
*
|
|
10
|
+
* Engine Features
|
|
11
|
+
* - Object oriented system with EngineObject base class
|
|
12
|
+
* - Automatic object lifecycle (update, physics, collision, rendering)
|
|
13
|
+
* - Engine helper classes: Vector2, Color, Timer, RandomGenerator
|
|
14
|
+
* - Hybrid rendering with WebGL batching and Canvas2D fallback
|
|
15
|
+
* - Audio system with wave, mp3, or ZzFX sound effects
|
|
16
|
+
* - Input system with keyboard, mouse, gamepad, and touch support
|
|
17
|
+
* - Tile layer rendering and collision detection
|
|
18
|
+
* - Particle effect system with emitters
|
|
19
|
+
* - Medal/achievement system with local storage
|
|
20
|
+
* - Comprehensive debug tools and visualizations
|
|
21
|
+
* - Fixed 60 FPS timestep with configurable time scale
|
|
22
|
+
* - Raycast and spatial query utilities
|
|
23
|
+
* - Plugin system for extending engine functionality
|
|
24
|
+
* - Start with engineInit() and provide your game callbacks
|
|
25
|
+
* @namespace Engine
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Name of engine
|
|
29
|
+
* @type {string}
|
|
30
|
+
* @default
|
|
31
|
+
* @memberof Engine */
|
|
32
|
+
const engineName = 'LittleJS';
|
|
33
|
+
|
|
34
|
+
/** Version of engine
|
|
35
|
+
* @type {string}
|
|
36
|
+
* @default
|
|
37
|
+
* @memberof Engine */
|
|
38
|
+
const engineVersion = '1.18.26';
|
|
39
|
+
|
|
40
|
+
/** Frames per second to update
|
|
41
|
+
* @type {number}
|
|
42
|
+
* @default
|
|
43
|
+
* @memberof Engine */
|
|
44
|
+
const frameRate = 60;
|
|
45
|
+
|
|
46
|
+
/** How many seconds each frame lasts, engine uses a fixed time step
|
|
47
|
+
* @type {number}
|
|
48
|
+
* @default 1/60
|
|
49
|
+
* @memberof Engine */
|
|
50
|
+
const timeDelta = 1/frameRate;
|
|
51
|
+
|
|
52
|
+
/** Array containing all engine objects
|
|
53
|
+
* @type {Array<EngineObject>}
|
|
54
|
+
* @memberof Engine */
|
|
55
|
+
let engineObjects = [];
|
|
56
|
+
|
|
57
|
+
/** Array with only objects set to collide with other objects this frame (for optimization)
|
|
58
|
+
* @type {Array<EngineObject>}
|
|
59
|
+
* @memberof Engine */
|
|
60
|
+
let engineObjectsCollide = [];
|
|
61
|
+
|
|
62
|
+
/** Current update frame, used to calculate time
|
|
63
|
+
* @type {number}
|
|
64
|
+
* @memberof Engine */
|
|
65
|
+
let frame = 0;
|
|
66
|
+
|
|
67
|
+
/** Current engine time since start in seconds
|
|
68
|
+
* @type {number}
|
|
69
|
+
* @memberof Engine */
|
|
70
|
+
let time = 0;
|
|
71
|
+
|
|
72
|
+
/** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
|
|
73
|
+
* @type {number}
|
|
74
|
+
* @memberof Engine */
|
|
75
|
+
let timeReal = 0;
|
|
76
|
+
|
|
77
|
+
/** Is the game paused? Causes time and objects to not be updated
|
|
78
|
+
* @type {boolean}
|
|
79
|
+
* @default false
|
|
80
|
+
* @memberof Engine */
|
|
81
|
+
let paused = false;
|
|
82
|
+
|
|
83
|
+
/** Get if game is paused
|
|
84
|
+
* @return {boolean}
|
|
85
|
+
* @memberof Engine */
|
|
86
|
+
function getPaused() { return paused; }
|
|
87
|
+
|
|
88
|
+
/** Set if game is paused
|
|
89
|
+
* @param {boolean} [isPaused]
|
|
90
|
+
* @memberof Engine */
|
|
91
|
+
function setPaused(isPaused=true) { paused = isPaused; }
|
|
92
|
+
|
|
93
|
+
// Engine internal variables
|
|
94
|
+
let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
|
|
95
|
+
let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
|
|
96
|
+
let showEngineVersion = true;
|
|
97
|
+
|
|
98
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
99
|
+
// plugin hooks
|
|
100
|
+
|
|
101
|
+
const pluginList = [];
|
|
102
|
+
class EnginePlugin
|
|
103
|
+
{
|
|
104
|
+
constructor(update, render, glContextLost, glContextRestored)
|
|
105
|
+
{
|
|
106
|
+
this.update = update;
|
|
107
|
+
this.render = render;
|
|
108
|
+
this.glContextLost = glContextLost;
|
|
109
|
+
this.glContextRestored = glContextRestored;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @callback PluginCallback - Update or render function for a plugin
|
|
115
|
+
* @memberof Engine
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/** Add a new update function for a plugin
|
|
119
|
+
* @param {PluginCallback} [update]
|
|
120
|
+
* @param {PluginCallback} [render]
|
|
121
|
+
* @param {PluginCallback} [glContextLost]
|
|
122
|
+
* @param {PluginCallback} [glContextRestored]
|
|
123
|
+
* @memberof Engine */
|
|
124
|
+
function engineAddPlugin(update, render, glContextLost, glContextRestored)
|
|
125
|
+
{
|
|
126
|
+
// make sure plugin functions are unique
|
|
127
|
+
ASSERT(!pluginList.find(p=>
|
|
128
|
+
p.update === update && p.render === render &&
|
|
129
|
+
p.glContextLost === glContextLost &&
|
|
130
|
+
p.glContextRestored === glContextRestored));
|
|
131
|
+
|
|
132
|
+
const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored);
|
|
133
|
+
pluginList.push(plugin);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
137
|
+
// Main Engine Functions
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @callback GameInitCallback - Called after the engine starts, can be async
|
|
141
|
+
* @return {void|Promise<void>}
|
|
142
|
+
* @memberof Engine
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* @callback GameCallback - Update or render function for the game
|
|
146
|
+
* @memberof Engine
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/** Startup LittleJS engine with your callback functions
|
|
150
|
+
* @param {GameInitCallback} gameInit - Called once after the engine starts up, can be async for loading
|
|
151
|
+
* @param {GameCallback} gameUpdate - Called every frame before objects are updated (60fps), use for game logic
|
|
152
|
+
* @param {GameCallback} gameUpdatePost - Called after physics and objects are updated, even when paused, use for UI updates
|
|
153
|
+
* @param {GameCallback} gameRender - Called before objects are rendered, use for drawing backgrounds/world elements
|
|
154
|
+
* @param {GameCallback} gameRenderPost - Called after objects are rendered, use for drawing UI/overlays
|
|
155
|
+
* @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
|
|
156
|
+
* @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
|
|
157
|
+
* @example
|
|
158
|
+
* // Basic engine startup
|
|
159
|
+
* engineInit(
|
|
160
|
+
* ()=> { LOG('Game initialized!'); }, // gameInit
|
|
161
|
+
* ()=> { updateGameLogic(); }, // gameUpdate
|
|
162
|
+
* ()=> { updateUI(); }, // gameUpdatePost
|
|
163
|
+
* ()=> { drawBackground(); }, // gameRender
|
|
164
|
+
* ()=> { drawHUD(); }, // gameRenderPost
|
|
165
|
+
* ['tiles.png', 'tilesLevel.png'] // images to load
|
|
166
|
+
* );
|
|
167
|
+
* @memberof Engine */
|
|
168
|
+
async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
|
|
169
|
+
{
|
|
170
|
+
showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
|
|
171
|
+
ASSERT(!mainContext, 'engine already initialized');
|
|
172
|
+
// runtime guard so release builds (where the assert is stripped) don't
|
|
173
|
+
// double-register listeners / double-add canvases on a second call
|
|
174
|
+
if (mainContext) return;
|
|
175
|
+
ASSERT(isArray(imageSources), 'pass in images as array');
|
|
176
|
+
|
|
177
|
+
// ensure body exists for minimal HTML where the script runs before <body> is parsed
|
|
178
|
+
if (!document.body)
|
|
179
|
+
document.documentElement.appendChild(document.createElement('body'));
|
|
180
|
+
rootElement ||= document.body;
|
|
181
|
+
|
|
182
|
+
// allow passing in empty functions
|
|
183
|
+
gameInit ||= ()=>{};
|
|
184
|
+
gameUpdate ||= ()=>{};
|
|
185
|
+
gameUpdatePost ||= ()=>{};
|
|
186
|
+
gameRender ||= ()=>{};
|
|
187
|
+
gameRenderPost ||= ()=>{};
|
|
188
|
+
|
|
189
|
+
// Called automatically by engine to setup render system
|
|
190
|
+
function enginePreRender()
|
|
191
|
+
{
|
|
192
|
+
// save canvas size
|
|
193
|
+
mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
|
|
194
|
+
|
|
195
|
+
// disable smoothing for pixel art
|
|
196
|
+
mainContext.imageSmoothingEnabled = !tilesPixelated;
|
|
197
|
+
|
|
198
|
+
// setup gl rendering if enabled
|
|
199
|
+
glPreRender();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// internal update loop for engine
|
|
203
|
+
function engineUpdate(frameTimeMS=0)
|
|
204
|
+
{
|
|
205
|
+
// update time keeping
|
|
206
|
+
let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
|
|
207
|
+
// skip delta on the very first frame so timeReal doesn't jump
|
|
208
|
+
// by ~page-load-time when RAF starts handing real timestamps
|
|
209
|
+
if (!frameTimeLastMS) frameTimeDeltaMS = 0;
|
|
210
|
+
frameTimeLastMS = frameTimeMS;
|
|
211
|
+
if (debug || debugWatermark)
|
|
212
|
+
averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
|
|
213
|
+
const debugSpeedUp = debug && keyIsDown('Equal'); // +
|
|
214
|
+
const debugSpeedDown = debug && keyIsDown('Minus'); // -
|
|
215
|
+
const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
|
|
216
|
+
|
|
217
|
+
// apply time deltas
|
|
218
|
+
timeReal += frameTimeDeltaMS * debugScale / 1e3;
|
|
219
|
+
const combinedScale = timeScale * debugScale;
|
|
220
|
+
frameTimeDeltaMS *= combinedScale;
|
|
221
|
+
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
|
|
222
|
+
if (combinedScale <= 1)
|
|
223
|
+
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
|
|
224
|
+
|
|
225
|
+
let wasUpdated = false;
|
|
226
|
+
if (paused)
|
|
227
|
+
{
|
|
228
|
+
// update everything except the game and objects
|
|
229
|
+
wasUpdated = true;
|
|
230
|
+
updateCanvas();
|
|
231
|
+
inputUpdate();
|
|
232
|
+
pluginList.forEach(plugin=>plugin.update?.());
|
|
233
|
+
|
|
234
|
+
// update object transforms even when paused
|
|
235
|
+
for (const o of engineObjects)
|
|
236
|
+
o.parent || o.updateTransforms();
|
|
237
|
+
|
|
238
|
+
// do post update
|
|
239
|
+
debugUpdate();
|
|
240
|
+
gameUpdatePost();
|
|
241
|
+
inputUpdatePost();
|
|
242
|
+
if (debugVideoCaptureIsActive())
|
|
243
|
+
renderFrame();
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
{
|
|
247
|
+
// apply time delta smoothing, improves smoothness of framerate in some browsers
|
|
248
|
+
let deltaSmooth = 0;
|
|
249
|
+
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
|
|
250
|
+
{
|
|
251
|
+
// force at least one update each frame since it is waiting for refresh
|
|
252
|
+
deltaSmooth = frameTimeBufferMS;
|
|
253
|
+
frameTimeBufferMS = 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// update multiple frames if necessary in case of slow framerate
|
|
257
|
+
for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
|
|
258
|
+
{
|
|
259
|
+
// increment frame and update time
|
|
260
|
+
time = frame++ / frameRate;
|
|
261
|
+
|
|
262
|
+
// update game and objects
|
|
263
|
+
wasUpdated = true;
|
|
264
|
+
updateCanvas();
|
|
265
|
+
inputUpdate();
|
|
266
|
+
gameUpdate();
|
|
267
|
+
pluginList.forEach(plugin=>plugin.update?.());
|
|
268
|
+
engineObjectsUpdate();
|
|
269
|
+
|
|
270
|
+
// do post update
|
|
271
|
+
debugUpdate();
|
|
272
|
+
gameUpdatePost();
|
|
273
|
+
inputUpdatePost();
|
|
274
|
+
if (debugVideoCaptureIsActive())
|
|
275
|
+
renderFrame();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// add the time smoothing back in
|
|
279
|
+
frameTimeBufferMS += deltaSmooth;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!debugVideoCaptureIsActive())
|
|
283
|
+
renderFrame();
|
|
284
|
+
if (!engineManualStep)
|
|
285
|
+
requestAnimationFrame(engineUpdate);
|
|
286
|
+
|
|
287
|
+
function renderFrame()
|
|
288
|
+
{
|
|
289
|
+
if (headlessMode) return;
|
|
290
|
+
|
|
291
|
+
// canvas must be updated before rendering
|
|
292
|
+
if (!wasUpdated)
|
|
293
|
+
updateCanvas();
|
|
294
|
+
|
|
295
|
+
// render the game and objects
|
|
296
|
+
enginePreRender();
|
|
297
|
+
gameRender();
|
|
298
|
+
engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
|
|
299
|
+
for (const o of engineObjects)
|
|
300
|
+
o.destroyed || o.render();
|
|
301
|
+
|
|
302
|
+
// post rendering
|
|
303
|
+
gameRenderPost();
|
|
304
|
+
pluginList.forEach(plugin=>plugin.render?.());
|
|
305
|
+
inputRender();
|
|
306
|
+
debugRender();
|
|
307
|
+
glFlush();
|
|
308
|
+
debugRenderPost();
|
|
309
|
+
drawCount = 0;
|
|
310
|
+
primitiveCount = 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
engineUpdateInternal = engineUpdate;
|
|
314
|
+
|
|
315
|
+
function updateCanvas()
|
|
316
|
+
{
|
|
317
|
+
if (headlessMode) return;
|
|
318
|
+
|
|
319
|
+
if (canvasFixedSize.x)
|
|
320
|
+
{
|
|
321
|
+
// set canvas fixed size
|
|
322
|
+
mainCanvasSize = canvasFixedSize.copy();
|
|
323
|
+
|
|
324
|
+
// fit to window using css width and height
|
|
325
|
+
const innerAspect = innerWidth / innerHeight;
|
|
326
|
+
const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
|
|
327
|
+
const w = innerAspect < fixedAspect ? '100%' : '';
|
|
328
|
+
const h = innerAspect < fixedAspect ? '' : '100%';
|
|
329
|
+
mainCanvas.style.width = w;
|
|
330
|
+
mainCanvas.style.height = h;
|
|
331
|
+
if (glCanvas)
|
|
332
|
+
{
|
|
333
|
+
glCanvas.style.width = w;
|
|
334
|
+
glCanvas.style.height = h;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
{
|
|
339
|
+
// apply device pixel ratio for crisp rendering
|
|
340
|
+
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
341
|
+
const viewWidth = innerWidth * dpr | 0;
|
|
342
|
+
const viewHeight = innerHeight * dpr | 0;
|
|
343
|
+
|
|
344
|
+
// get main canvas size based on window size
|
|
345
|
+
mainCanvasSize.x = min(viewWidth, canvasMaxSize.x);
|
|
346
|
+
mainCanvasSize.y = min(viewHeight, canvasMaxSize.y);
|
|
347
|
+
|
|
348
|
+
// responsive aspect ratio with native resolution
|
|
349
|
+
const innerAspect = viewWidth / viewHeight;
|
|
350
|
+
ASSERT(canvasMinAspect <= canvasMaxAspect);
|
|
351
|
+
if (canvasMaxAspect && innerAspect > canvasMaxAspect)
|
|
352
|
+
{
|
|
353
|
+
// full height
|
|
354
|
+
const w = mainCanvasSize.y * canvasMaxAspect | 0;
|
|
355
|
+
mainCanvasSize.x = min(w, canvasMaxSize.x);
|
|
356
|
+
}
|
|
357
|
+
else if (innerAspect < canvasMinAspect)
|
|
358
|
+
{
|
|
359
|
+
// full width
|
|
360
|
+
const h = mainCanvasSize.x / canvasMinAspect | 0;
|
|
361
|
+
mainCanvasSize.y = min(h, canvasMaxSize.y);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// set CSS display size so backing store renders at viewport size
|
|
365
|
+
const cssW = (mainCanvasSize.x / dpr | 0) + 'px';
|
|
366
|
+
const cssH = (mainCanvasSize.y / dpr | 0) + 'px';
|
|
367
|
+
mainCanvas.style.width = cssW;
|
|
368
|
+
mainCanvas.style.height = cssH;
|
|
369
|
+
if (glCanvas)
|
|
370
|
+
{
|
|
371
|
+
glCanvas.style.width = cssW;
|
|
372
|
+
glCanvas.style.height = cssH;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// clear main canvas and set size
|
|
377
|
+
mainCanvas.width = mainCanvasSize.x;
|
|
378
|
+
mainCanvas.height = mainCanvasSize.y;
|
|
379
|
+
|
|
380
|
+
// apply the clear color to main canvas
|
|
381
|
+
if (canvasClearColor.a > 0 && !glEnable)
|
|
382
|
+
{
|
|
383
|
+
mainContext.fillStyle = canvasClearColor.toString();
|
|
384
|
+
mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
385
|
+
mainContext.fillStyle = BLACK.toString();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// set default line join and cap
|
|
389
|
+
mainContext.lineJoin = 'round';
|
|
390
|
+
mainContext.lineCap = 'round';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// skip setup if headless
|
|
394
|
+
if (headlessMode) return startEngine();
|
|
395
|
+
|
|
396
|
+
// setup webgl
|
|
397
|
+
glInit(rootElement);
|
|
398
|
+
|
|
399
|
+
// setup html
|
|
400
|
+
const styleRoot =
|
|
401
|
+
'margin:0;' + // fill the window
|
|
402
|
+
'overflow:hidden;' + // no scroll bars
|
|
403
|
+
'background:#000;' + // set background color
|
|
404
|
+
'user-select:none;' + // prevent hold to select
|
|
405
|
+
'-webkit-user-select:none;' + // compatibility for ios
|
|
406
|
+
'touch-action:none;' + // prevent mobile pinch to resize
|
|
407
|
+
'-webkit-touch-callout:none'; // compatibility for ios
|
|
408
|
+
rootElement.style.cssText = styleRoot;
|
|
409
|
+
mainCanvas = rootElement.appendChild(document.createElement('canvas'));
|
|
410
|
+
drawContext = mainContext = mainCanvas.getContext('2d');
|
|
411
|
+
|
|
412
|
+
// init stuff and start engine
|
|
413
|
+
inputInit();
|
|
414
|
+
audioInit();
|
|
415
|
+
debugInit();
|
|
416
|
+
|
|
417
|
+
// setup canvases
|
|
418
|
+
// transform way is still more reliable than flexbox or grid
|
|
419
|
+
const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
|
|
420
|
+
'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
|
|
421
|
+
mainCanvas.style.cssText = styleCanvas;
|
|
422
|
+
if (glCanvas)
|
|
423
|
+
glCanvas.style.cssText = styleCanvas;
|
|
424
|
+
setCanvasPixelated(canvasPixelated);
|
|
425
|
+
updateCanvas();
|
|
426
|
+
glPreRender();
|
|
427
|
+
|
|
428
|
+
// create offscreen canvases for image processing
|
|
429
|
+
workCanvas = new OffscreenCanvas(64, 64);
|
|
430
|
+
workContext = workCanvas.getContext('2d');
|
|
431
|
+
workReadCanvas = new OffscreenCanvas(64, 64);
|
|
432
|
+
workReadContext = workReadCanvas.getContext('2d', { willReadFrequently: true });
|
|
433
|
+
|
|
434
|
+
// create promises for loading images
|
|
435
|
+
const promises = imageSources.map((src, i)=> loadTexture(i, src));
|
|
436
|
+
|
|
437
|
+
// no images to load
|
|
438
|
+
if (!imageSources.length)
|
|
439
|
+
promises.push(loadTexture(0));
|
|
440
|
+
|
|
441
|
+
// load engine font image
|
|
442
|
+
promises.push(imageFontInit());
|
|
443
|
+
|
|
444
|
+
if (showSplashScreen)
|
|
445
|
+
{
|
|
446
|
+
// draw splash screen
|
|
447
|
+
promises.push(new Promise(resolve =>
|
|
448
|
+
{
|
|
449
|
+
let t = 0;
|
|
450
|
+
updateSplash();
|
|
451
|
+
function updateSplash()
|
|
452
|
+
{
|
|
453
|
+
inputClear();
|
|
454
|
+
drawEngineLogo(t+=.01);
|
|
455
|
+
t>1 ? resolve() : setTimeout(updateSplash, 16);
|
|
456
|
+
}
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// wait for all the promises to finish
|
|
461
|
+
await Promise.all(promises);
|
|
462
|
+
return startEngine();
|
|
463
|
+
|
|
464
|
+
async function startEngine()
|
|
465
|
+
{
|
|
466
|
+
// wait for gameInit to load
|
|
467
|
+
await gameInit();
|
|
468
|
+
engineManualStep || engineUpdate();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// max frames engineStep can advance in one call, 10 minutes at 60fps
|
|
473
|
+
// large counts block until they finish, so this catches runaway values
|
|
474
|
+
const engineStepMaxFrames = 36000;
|
|
475
|
+
|
|
476
|
+
/** Advance the engine by a number of frames
|
|
477
|
+
* Requires setEngineManualStep(true) before engineInit
|
|
478
|
+
* Respects paused exactly as the normal update loop does
|
|
479
|
+
* @param {number} [frames] - number of engine update ticks, max 36000, each running one fixed update at timeScale 1
|
|
480
|
+
* @example
|
|
481
|
+
* setHeadlessMode(true);
|
|
482
|
+
* setEngineManualStep(true);
|
|
483
|
+
* await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
|
|
484
|
+
* engineStep(600); // advance 10 seconds of game time
|
|
485
|
+
* @memberof Engine */
|
|
486
|
+
function engineStep(frames=1)
|
|
487
|
+
{
|
|
488
|
+
ASSERT(engineManualStep,
|
|
489
|
+
'engineStep requires setEngineManualStep(true) before engineInit');
|
|
490
|
+
ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
|
|
491
|
+
// runtime guard so release builds (where the asserts are stripped) can't
|
|
492
|
+
// start a second requestAnimationFrame chain or call an undefined update
|
|
493
|
+
if (!engineManualStep || !engineUpdateInternal) return;
|
|
494
|
+
ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
|
|
495
|
+
'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
|
|
496
|
+
frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
|
|
497
|
+
for (let i = frames; i > 0; --i)
|
|
498
|
+
engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Update each engine object, remove destroyed objects, and update time
|
|
502
|
+
* can be called manually if objects need to be updated outside of main loop
|
|
503
|
+
* @memberof Engine */
|
|
504
|
+
function engineObjectsUpdate()
|
|
505
|
+
{
|
|
506
|
+
// get list of solid objects for physics optimization
|
|
507
|
+
engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
|
|
508
|
+
|
|
509
|
+
// update physics before object update
|
|
510
|
+
for (const o of engineObjects)
|
|
511
|
+
if (!o.parent && !o.destroyed)
|
|
512
|
+
o.updatePhysics();
|
|
513
|
+
|
|
514
|
+
// recursive object update
|
|
515
|
+
function updateChildObject(o)
|
|
516
|
+
{
|
|
517
|
+
if (o.destroyed) return;
|
|
518
|
+
|
|
519
|
+
o.update();
|
|
520
|
+
for (const child of o.children)
|
|
521
|
+
updateChildObject(child);
|
|
522
|
+
}
|
|
523
|
+
for (const o of engineObjects)
|
|
524
|
+
{
|
|
525
|
+
if (o.parent || o.destroyed) continue;
|
|
526
|
+
|
|
527
|
+
// update top level objects
|
|
528
|
+
o.update();
|
|
529
|
+
for (const child of o.children)
|
|
530
|
+
updateChildObject(child);
|
|
531
|
+
o.updateTransforms();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// remove destroyed objects
|
|
535
|
+
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Destroy and remove all objects
|
|
539
|
+
* - This can be used to clear out all objects when restarting a level
|
|
540
|
+
* - Objects can override their destroy function to do cleanup or stick around
|
|
541
|
+
* @param {boolean} [immediate] - should attached effects be allowed to die off?
|
|
542
|
+
* @memberof Engine */
|
|
543
|
+
function engineObjectsDestroy(immediate=true)
|
|
544
|
+
{
|
|
545
|
+
for (const o of engineObjects)
|
|
546
|
+
o.parent || o.destroy(immediate);
|
|
547
|
+
engineObjects = engineObjects.filter(o=>!o.destroyed);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Collects all object within a given area
|
|
551
|
+
* @param {Vector2} [pos] - Center of test area, or undefined for all objects
|
|
552
|
+
* @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
|
|
553
|
+
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
554
|
+
* @return {Array<EngineObject>} - List of collected objects
|
|
555
|
+
* @memberof Engine */
|
|
556
|
+
function engineObjectsCollect(pos, size, objects=engineObjects)
|
|
557
|
+
{
|
|
558
|
+
const collectedObjects = [];
|
|
559
|
+
if (!pos)
|
|
560
|
+
{
|
|
561
|
+
// all objects
|
|
562
|
+
for (const o of objects)
|
|
563
|
+
collectedObjects.push(o);
|
|
564
|
+
}
|
|
565
|
+
else if (size instanceof Vector2)
|
|
566
|
+
{
|
|
567
|
+
// bounding box test
|
|
568
|
+
for (const o of objects)
|
|
569
|
+
o.isOverlapping(pos, size) && collectedObjects.push(o);
|
|
570
|
+
}
|
|
571
|
+
else
|
|
572
|
+
{
|
|
573
|
+
// circle test
|
|
574
|
+
const sizeSquared = size*size;
|
|
575
|
+
for (const o of objects)
|
|
576
|
+
pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
|
|
577
|
+
}
|
|
578
|
+
return collectedObjects;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @callback ObjectCallbackFunction - Function that processes an object
|
|
583
|
+
* @param {EngineObject} object
|
|
584
|
+
* @memberof Engine
|
|
585
|
+
*/
|
|
586
|
+
|
|
587
|
+
/** Triggers a callback for each object within a given area
|
|
588
|
+
* @param {Vector2} [pos] - Center of test area, or undefined for all objects
|
|
589
|
+
* @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2
|
|
590
|
+
* @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test
|
|
591
|
+
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
592
|
+
* @memberof Engine */
|
|
593
|
+
function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects)
|
|
594
|
+
{ engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); }
|
|
595
|
+
|
|
596
|
+
/** Return a list of objects intersecting a ray
|
|
597
|
+
* @param {Vector2} start
|
|
598
|
+
* @param {Vector2} end
|
|
599
|
+
* @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
|
|
600
|
+
* @return {Array<EngineObject>} - List of objects hit
|
|
601
|
+
* @memberof Engine */
|
|
602
|
+
function engineObjectsRaycast(start, end, objects=engineObjects)
|
|
603
|
+
{
|
|
604
|
+
const hitObjects = [];
|
|
605
|
+
for (const o of objects)
|
|
606
|
+
{
|
|
607
|
+
if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size))
|
|
608
|
+
{
|
|
609
|
+
debugRaycast && debugRect(o.pos, o.size, '#f00');
|
|
610
|
+
hitObjects.push(o);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
|
|
615
|
+
return hitObjects;
|
|
616
616
|
}
|
|
617
617
|
/**
|
|
618
618
|
* LittleJS Debug System
|
|
@@ -5525,9 +5525,15 @@ class ImageFont
|
|
|
5525
5525
|
tileInfo.pos.x = x*sizePaddedX + padding;
|
|
5526
5526
|
tileInfo.pos.y = y*sizePaddedY + padding;
|
|
5527
5527
|
|
|
5528
|
-
//
|
|
5529
|
-
|
|
5530
|
-
|
|
5528
|
+
// snap the glyph edges to whole pixels
|
|
5529
|
+
// tiles are drawn from their center, so snapping the center
|
|
5530
|
+
// to a whole pixel puts the edges on half pixels when the
|
|
5531
|
+
// size is even, and a row or column of the glyph then has
|
|
5532
|
+
// no pixel center inside it and is not rasterized at all
|
|
5533
|
+
// ceil picks the nearest aligned position, breaking ties
|
|
5534
|
+
// downward to match how this used to truncate
|
|
5535
|
+
drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
|
|
5536
|
+
drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
|
|
5531
5537
|
drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
|
|
5532
5538
|
}
|
|
5533
5539
|
});
|