littlejsengine 1.26.1 → 1.27.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/CHANGELOG.md +16 -0
- package/FAQ.md +3 -2
- package/README.md +4 -3
- package/REFERENCE.md +38 -0
- package/dist/littlejs.d.ts +181 -16
- package/dist/littlejs.esm.js +810 -98
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +810 -98
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +790 -92
- package/package.json +1 -1
- package/plugins/lightSystem.js +84 -2
- package/plugins/newgrounds.js +92 -0
- package/plugins/parallax.js +3 -1
- package/plugins/render3dEditor.js +20 -6
- package/plugins/render3dExtras.js +1 -0
- package/plugins/wavedash.js +235 -4
- package/src/engine.js +24 -3
- package/src/engineObject.js +244 -65
- package/src/engineParticles.js +8 -3
- package/src/engineTileLayer.js +95 -10
- package/src/engineWebGL.js +4 -4
package/package.json
CHANGED
package/plugins/lightSystem.js
CHANGED
|
@@ -228,20 +228,34 @@ class LightSystemPlugin
|
|
|
228
228
|
'uniform vec4 color;'+
|
|
229
229
|
'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
|
|
230
230
|
'uniform bool useShadow;'+
|
|
231
|
+
'uniform vec2 forward;'+ // the light's up, the way its cone and gel look
|
|
232
|
+
'uniform vec2 cone;'+ // the cone's factor and its edge times it, (0, -1) for none
|
|
233
|
+
'uniform sampler2D gelTexture;'+
|
|
234
|
+
'uniform vec4 gelRect;'+ // the gel's tile in its texture, corner and size
|
|
235
|
+
'uniform bool useGel;'+
|
|
231
236
|
'in vec2 vWorldPos;'+
|
|
232
237
|
'in vec2 vUV;'+
|
|
233
238
|
'out vec4 c;'+
|
|
234
239
|
'void main(){'+
|
|
235
|
-
'
|
|
240
|
+
'vec2 d=vWorldPos-lightPos;'+
|
|
241
|
+
'float dist=length(d);'+
|
|
236
242
|
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
237
243
|
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
238
244
|
'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+
|
|
245
|
+
// the cone: full where its fade starts, nothing at its edge, smooth between, as a 3D spotlight's
|
|
246
|
+
'float k=clamp(dot(forward,d/max(dist,1e-6))*cone.x-cone.y,0.,1.);'+
|
|
247
|
+
'c.rgb*=k*k*(3.-2.*k);'+
|
|
248
|
+
// the gel across the light's square, turned with it, its top the way the light looks
|
|
249
|
+
'if(useGel){'+
|
|
250
|
+
'vec2 g=clamp(vec2(dot(d,vec2(forward.y,-forward.x)),dot(d,forward))/(radius*2.)+.5,0.,1.);'+
|
|
251
|
+
'c.rgb*=texture(gelTexture,gelRect.xy+vec2(g.x,1.-g.y)*gelRect.zw).rgb;}'+
|
|
239
252
|
'}'
|
|
240
253
|
);
|
|
241
254
|
if (lightSystemShadersFailed(lightSystem.lightShader)) return;
|
|
242
|
-
// the shadow texture is on unit 1, the engine's tracked texture stays on unit 0
|
|
255
|
+
// the shadow texture is on unit 1 and the gel on 2, the engine's tracked texture stays on unit 0
|
|
243
256
|
glContext.useProgram(lightSystem.lightShader);
|
|
244
257
|
glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
|
|
258
|
+
glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2);
|
|
245
259
|
|
|
246
260
|
// composite shader: fullscreen quad, samples the lightmap
|
|
247
261
|
lightSystem.compositeShader = glCreateProgram(
|
|
@@ -814,6 +828,19 @@ class LightSystemPlugin
|
|
|
814
828
|
[drawContext, glCustomShader] = saved;
|
|
815
829
|
}
|
|
816
830
|
}
|
|
831
|
+
catch (error)
|
|
832
|
+
{
|
|
833
|
+
// a renderLight or renderEmissive threw: what it queued goes to the lightmap, then the screen is the
|
|
834
|
+
// target again with its viewport and blend, so a game that catches the error draws to the screen; a
|
|
835
|
+
// flush that throws too is let go of, the first error is the one that counts
|
|
836
|
+
try { glFlush(); } catch (e) {}
|
|
837
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
838
|
+
glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
|
|
839
|
+
glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
840
|
+
glSetInstancedMode(true);
|
|
841
|
+
setAdditiveBlendMode(prevAdditive);
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
817
844
|
finally
|
|
818
845
|
{
|
|
819
846
|
glRenderTargetBase = undefined;
|
|
@@ -927,6 +954,18 @@ class LightSystemPlugin
|
|
|
927
954
|
const c = light.color;
|
|
928
955
|
glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
929
956
|
|
|
957
|
+
// the cone and the gel look along the light's up
|
|
958
|
+
const forward = light.getUp();
|
|
959
|
+
glContext.uniform2f(glUniformLocation(ls, 'forward'), forward.x, forward.y);
|
|
960
|
+
const [coneFactor, coneEdge] = lightSystemCone(light);
|
|
961
|
+
glContext.uniform2f(glUniformLocation(ls, 'cone'), coneFactor, coneEdge);
|
|
962
|
+
const gel = lightSystemGel(light);
|
|
963
|
+
glContext.uniform1i(glUniformLocation(ls, 'useGel'), gel ? 1 : 0);
|
|
964
|
+
glContext.activeTexture(glContext.TEXTURE2);
|
|
965
|
+
glContext.bindTexture(glContext.TEXTURE_2D, gel ? gel.texture : null);
|
|
966
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
967
|
+
gel && glContext.uniform4fv(glUniformLocation(ls, 'gelRect'), gel.rect);
|
|
968
|
+
|
|
930
969
|
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
931
970
|
|
|
932
971
|
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
@@ -1004,6 +1043,31 @@ class LightSystemPlugin
|
|
|
1004
1043
|
|
|
1005
1044
|
///////////////////////////////////////////////////////////////////////////////
|
|
1006
1045
|
|
|
1046
|
+
// a light's cone for its shader, the factor and its edge times it: the dot of its up and a direction times the factor,
|
|
1047
|
+
// less the edge, is 1 where the fade starts and 0 at the edge; (0, -1) for no cone, which leaves the light as it was
|
|
1048
|
+
function lightSystemCone(light)
|
|
1049
|
+
{
|
|
1050
|
+
const angle = min(light.coneAngle, PI);
|
|
1051
|
+
if (!(angle > 0)) return [0, -1];
|
|
1052
|
+
const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
|
|
1053
|
+
const k = 1 / max(inner - outer, 1e-4);
|
|
1054
|
+
return [k, outer * k];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// a light's gel for its shader, its texture and its tile's corner and size in it, a half texel in so the tiles beside
|
|
1058
|
+
// it do not bleed in; undefined for none or one not loaded yet
|
|
1059
|
+
function lightSystemGel(light)
|
|
1060
|
+
{
|
|
1061
|
+
const gel = light.gel;
|
|
1062
|
+
if (!gel) return;
|
|
1063
|
+
const isTile = gel instanceof TileInfo, textureInfo = isTile ? gel.textureInfo : gel;
|
|
1064
|
+
const texture = textureInfo?.glTexture, size = textureInfo?.size;
|
|
1065
|
+
if (!texture || !(size?.x > 0)) return;
|
|
1066
|
+
const pos = isTile ? gel.pos : vec2(), tileSize = isTile ? gel.size : size;
|
|
1067
|
+
return {texture, rect: [(pos.x + .5) / size.x, (pos.y + .5) / size.y,
|
|
1068
|
+
(tileSize.x - 1) / size.x, (tileSize.y - 1) / size.y]};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1007
1071
|
/**
|
|
1008
1072
|
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
1009
1073
|
* to the LightSystem plugin's lightmap.
|
|
@@ -1012,11 +1076,16 @@ class LightSystemPlugin
|
|
|
1012
1076
|
* - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
|
|
1013
1077
|
* carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
|
|
1014
1078
|
* the light's spot out
|
|
1079
|
+
* - A coneAngle makes it a cone along its up, turned by its angle, and a gel is a picture it shines through; both
|
|
1080
|
+
* are applied over the round light with its shadows, so they cost next to nothing
|
|
1015
1081
|
* @extends EngineObject
|
|
1016
1082
|
* @memberof LightSystem
|
|
1017
1083
|
* @example
|
|
1018
1084
|
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
1019
1085
|
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
|
|
1086
|
+
* const flashlight = new Light(vec2(), 10); // a flashlight looking right, its beam 60 degrees across
|
|
1087
|
+
* flashlight.coneAngle = PI/6;
|
|
1088
|
+
* flashlight.angle = PI/2;
|
|
1020
1089
|
*/
|
|
1021
1090
|
class Light extends EngineObject
|
|
1022
1091
|
{
|
|
@@ -1049,6 +1118,19 @@ class Light extends EngineObject
|
|
|
1049
1118
|
this.glowFalloff = 1;
|
|
1050
1119
|
/** @type {TileInfo|undefined} */
|
|
1051
1120
|
this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
|
|
1121
|
+
/** @property {number} - Makes it a cone, like a flashlight or headlight: the angle in radians from its up out
|
|
1122
|
+
* to the edge of the cone, so the beam is twice this across; it looks along getUp(), turned by its angle and
|
|
1123
|
+
* by what it is attached to; 0 for a light that shines every way */
|
|
1124
|
+
this.coneAngle = 0;
|
|
1125
|
+
/** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
|
|
1126
|
+
* fifth, 1 fading all the way from the middle of the beam */
|
|
1127
|
+
this.coneSoftness = .2;
|
|
1128
|
+
/** @property {TileInfo|TextureInfo|undefined} - A gel, a picture the light shines through, called a cookie in
|
|
1129
|
+
* some engines: stretched across the light's square, twice its radius, its top the way the light looks,
|
|
1130
|
+
* turned with it, and multiplied into its color, so one light can be a car's headlights and tail lights;
|
|
1131
|
+
* a TileInfo's own tile, or a whole TextureInfo; shadows still fall through it
|
|
1132
|
+
* @type {TileInfo|TextureInfo|undefined} */
|
|
1133
|
+
this.gel = undefined;
|
|
1052
1134
|
}
|
|
1053
1135
|
|
|
1054
1136
|
/** Lights are invisible in the main render pass — they only contribute
|
package/plugins/newgrounds.js
CHANGED
|
@@ -163,6 +163,8 @@ class NewgroundsPlugin
|
|
|
163
163
|
/** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
|
|
164
164
|
* @type {Array<Object>} */
|
|
165
165
|
this.scoreboards = [];
|
|
166
|
+
/** @type {Map<number, 'failed'|'notSave'>} */
|
|
167
|
+
this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure
|
|
166
168
|
/** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
|
|
167
169
|
* @type {{id: number, name: string, url: string, supporter: boolean}|null} */
|
|
168
170
|
this.user = null;
|
|
@@ -332,6 +334,96 @@ class NewgroundsPlugin
|
|
|
332
334
|
return this.call('ScoreBoard.getScores', parameters, session_id);
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
/** Save a value to one of the player's cloud save slots, which needs a logged in player
|
|
338
|
+
* - Any value JSON can hold; the slots are numbered from 1, as many as the app's Newgrounds settings give it
|
|
339
|
+
* @param {number} slot - The slot number
|
|
340
|
+
* @param {*} data - The value to save
|
|
341
|
+
* @return {Promise<boolean>} - Whether it saved, false when not logged in
|
|
342
|
+
* @example
|
|
343
|
+
* newgrounds.cloudSave(1, {level, coins}); */
|
|
344
|
+
async cloudSave(slot, data)
|
|
345
|
+
{
|
|
346
|
+
ASSERT(isNumber(slot), 'Newgrounds cloudSave: slot must be a number', slot);
|
|
347
|
+
if (!this.session_id) return false;
|
|
348
|
+
let text;
|
|
349
|
+
try { text = JSON.stringify(data); } catch (error) {}
|
|
350
|
+
if (text === undefined)
|
|
351
|
+
return console.warn('Newgrounds cloudSave: slot ' + slot + ' was given a value JSON can not hold'), false;
|
|
352
|
+
const response = await this.call('CloudSave.setData', {'id':slot, 'data':text});
|
|
353
|
+
newgroundsSessionLost(response) && this.dropSession();
|
|
354
|
+
return !!response?.result?.data?.['success'];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Load the value a cloud save slot holds: null when Newgrounds says it holds none, undefined when it could not be
|
|
358
|
+
* loaded or the player is not logged in, said in the console when logged in
|
|
359
|
+
* - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
|
|
360
|
+
* it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
|
|
361
|
+
* - A saved null loads as null, as an empty slot does
|
|
362
|
+
* @param {number} slot - The slot number
|
|
363
|
+
* @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
|
|
364
|
+
* @example
|
|
365
|
+
* const save = await newgrounds.cloudLoad(1); */
|
|
366
|
+
async cloudLoad(slot)
|
|
367
|
+
{
|
|
368
|
+
ASSERT(isNumber(slot), 'Newgrounds cloudLoad: slot must be a number', slot);
|
|
369
|
+
const {value, failure} = await this.loadSlot(slot);
|
|
370
|
+
failure ? this.loadFailures.set(+slot, failure) : this.loadFailures.delete(+slot);
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
|
|
375
|
+
* game may offer to start over, 'failed' when it could not be loaded, as when not logged in, where a game must
|
|
376
|
+
* not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
|
|
377
|
+
* @param {number} slot - The slot number
|
|
378
|
+
* @return {'failed'|'notSave'|undefined} */
|
|
379
|
+
loadFailure(slot) { return this.loadFailures.get(+slot); }
|
|
380
|
+
|
|
381
|
+
/** A slot's load, its value and why it failed, if it did
|
|
382
|
+
* @param {number} slot
|
|
383
|
+
* @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
|
|
384
|
+
* @ignore */
|
|
385
|
+
async loadSlot(slot)
|
|
386
|
+
{
|
|
387
|
+
const failed = (message)=>
|
|
388
|
+
{
|
|
389
|
+
message && console.warn('Newgrounds could not load slot ' + slot + message);
|
|
390
|
+
return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
|
|
391
|
+
};
|
|
392
|
+
if (!this.session_id) return failed('');
|
|
393
|
+
const response = await this.call('CloudSave.loadSlot', {'id':slot});
|
|
394
|
+
newgroundsSessionLost(response) && this.dropSession();
|
|
395
|
+
const slotData = response?.result?.data?.['slot'];
|
|
396
|
+
if (!slotData) return failed('.');
|
|
397
|
+
const url = slotData['url']; // where the saved text is, none for an empty slot
|
|
398
|
+
if (!url) return {value: null, failure: undefined};
|
|
399
|
+
let text;
|
|
400
|
+
try
|
|
401
|
+
{
|
|
402
|
+
const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS);
|
|
403
|
+
const saved = await fetch(url, {'cache':'no-store', 'signal':signal});
|
|
404
|
+
if (!saved.ok) // an error page is not the save, even one that is JSON
|
|
405
|
+
return failed(': ' + saved.status);
|
|
406
|
+
text = await saved.text();
|
|
407
|
+
}
|
|
408
|
+
catch(e) { return failed(': ' + e); }
|
|
409
|
+
if (!text) return failed(', it came back empty'); // a save is never empty
|
|
410
|
+
try { return {value: JSON.parse(text), failure: undefined}; }
|
|
411
|
+
catch(e)
|
|
412
|
+
{
|
|
413
|
+
console.warn('Newgrounds cloudLoad: slot ' + slot + ' holds a file that is not a save');
|
|
414
|
+
return {value: undefined, failure: 'notSave'};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Count an event of the game's own on its Newgrounds stats page, like a level finished or a button pressed
|
|
419
|
+
* @param {string} name - The event's name
|
|
420
|
+
* @return {Promise<Object>} - The response JSON object, undefined when the call failed */
|
|
421
|
+
logEvent(name)
|
|
422
|
+
{
|
|
423
|
+
ASSERT(typeof name === 'string' && name !== '', 'Newgrounds logEvent: name must be a string', name);
|
|
424
|
+
return this.call('Event.logEvent', {'event_name':name, 'host':this.host});
|
|
425
|
+
}
|
|
426
|
+
|
|
335
427
|
/** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
|
|
336
428
|
* @param {string} text
|
|
337
429
|
* @return {Promise<string>} */
|
package/plugins/parallax.js
CHANGED
|
@@ -39,7 +39,9 @@ class ParallaxLayer extends CanvasLayer
|
|
|
39
39
|
* and 1 with the screen, a Vector2 to follow x and y by different amounts
|
|
40
40
|
* @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
|
|
41
41
|
* @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
|
|
42
|
-
* the image, given the canvas context, its size in pixels and the layer; mountains when not given
|
|
42
|
+
* the image, given the canvas context, its size in pixels and the layer; mountains when not given; called
|
|
43
|
+
* once when the layer is made and on redraw(), not each frame, so it draws with the canvas 2D context, not
|
|
44
|
+
* the engine's draws like drawTile
|
|
43
45
|
* @param {Vector2} [canvasSize] - Size of the image in pixels */
|
|
44
46
|
constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
|
|
45
47
|
canvasSize=vec2(512, 256))
|
|
@@ -36,8 +36,9 @@ function editor3DCameraStart()
|
|
|
36
36
|
camera.pos = from.pos.copy();
|
|
37
37
|
camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
|
|
38
38
|
camera.fov = from.fov;
|
|
39
|
-
camera.near = from.near;
|
|
40
39
|
camera.far = from.far;
|
|
40
|
+
// an orthographic camera's near may sit behind it, which a perspective view can not use, so it keeps its own
|
|
41
|
+
from.orthographic || (camera.near = from.near);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
// turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
|
|
@@ -92,6 +93,7 @@ function editor3DWithView(fn)
|
|
|
92
93
|
// called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
|
|
93
94
|
function editor3DCameraBegin()
|
|
94
95
|
{
|
|
96
|
+
editor3DCameraEnd(); // a frame that threw before its End gives the game its camera back first, not the editor's
|
|
95
97
|
if (!editor3DViewOn() || !editor3DCamera) return;
|
|
96
98
|
const r = render3D, camera = editor3DGameCamera = r.camera;
|
|
97
99
|
r.camera = editor3DCamera;
|
|
@@ -424,8 +426,17 @@ function editor3DSize(object)
|
|
|
424
426
|
const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
|
|
425
427
|
if (box) return box.size;
|
|
426
428
|
if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
|
|
427
|
-
const s = made.size3D, k = made.scale3D;
|
|
428
|
-
|
|
429
|
+
const s = made.size3D, k = made.scale3D, size = vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
|
|
430
|
+
if (!editor3DTurned(object)) return size;
|
|
431
|
+
|
|
432
|
+
// a turned one's box is the box around it turned, so it lands, drops and pastes by what it covers
|
|
433
|
+
const matrix = buildMatrix(vec3(), editor3DRotation(object).scale(PI / 180), size), high = vec3();
|
|
434
|
+
for (let i = 8; i--;)
|
|
435
|
+
{
|
|
436
|
+
const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
437
|
+
high.x = max(high.x, p.x), high.y = max(high.y, p.y), high.z = max(high.z, p.z);
|
|
438
|
+
}
|
|
439
|
+
return vec3(editor3DRound(high.x * 2), editor3DRound(high.y * 2), editor3DRound(high.z * 2));
|
|
429
440
|
}
|
|
430
441
|
|
|
431
442
|
// the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
|
|
@@ -1637,6 +1648,7 @@ function editor3DSetOpen(open)
|
|
|
1637
1648
|
if (open)
|
|
1638
1649
|
{
|
|
1639
1650
|
editor3DSetFreeCamera(false);
|
|
1651
|
+
pointerLockIsActive() && pointerLockExit(); // the game's captured mouse is let go, the editor needs it
|
|
1640
1652
|
editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
|
|
1641
1653
|
editor3DCamera || editor3DCameraStart();
|
|
1642
1654
|
editor3DGamePaused = paused;
|
|
@@ -2334,10 +2346,11 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
2334
2346
|
}
|
|
2335
2347
|
if (factor)
|
|
2336
2348
|
{
|
|
2349
|
+
// snapped and kept from nothing by its size, its sign kept, so a mirrored axis stays mirrored
|
|
2337
2350
|
const size = snap ? editor3DScaleStep : 0, least = size || .01;
|
|
2351
|
+
const fit = (v)=> (v < 0 ? -1 : 1) * max(editor3DSnap(abs(v), size), least);
|
|
2338
2352
|
const scale = s.scale.multiply(factor);
|
|
2339
|
-
editor3DSetTransform(object, undefined, undefined, vec3(
|
|
2340
|
-
max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
|
|
2353
|
+
editor3DSetTransform(object, undefined, undefined, vec3(fit(scale.x), fit(scale.y), fit(scale.z)));
|
|
2341
2354
|
}
|
|
2342
2355
|
}
|
|
2343
2356
|
});
|
|
@@ -2858,8 +2871,9 @@ function editor3DDrawLabels()
|
|
|
2858
2871
|
const drag = editor3DDrag;
|
|
2859
2872
|
if (drag?.kind === 'box')
|
|
2860
2873
|
{
|
|
2874
|
+
// on the main canvas, over the WebGL one, so a 3D pass drawn after the 2D scene does not cover it
|
|
2861
2875
|
const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
|
|
2862
|
-
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0,
|
|
2876
|
+
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, false, true));
|
|
2863
2877
|
}
|
|
2864
2878
|
}
|
|
2865
2879
|
|
|
@@ -1307,6 +1307,7 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
1307
1307
|
const out = this.particleData;
|
|
1308
1308
|
out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
|
|
1309
1309
|
out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
|
|
1310
|
+
out[k+16] = view.lifeTime, out[k+17] = view.age;
|
|
1310
1311
|
if (view.destroyed)
|
|
1311
1312
|
out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
|
|
1312
1313
|
return result;
|
package/plugins/wavedash.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LittleJS Wavedash Plugin
|
|
3
|
-
* - The Wavedash twin of the Newgrounds plugin: achievements and
|
|
3
|
+
* - The Wavedash twin of the Newgrounds plugin: achievements, leaderboards and cloud saves, the same shape so a game
|
|
4
|
+
* can switch, and stats and presence of Wavedash's own
|
|
4
5
|
* - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
|
|
5
6
|
* off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
|
|
6
7
|
* - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
|
|
@@ -32,17 +33,33 @@ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3};
|
|
|
32
33
|
// the SDK Wavedash put on the page, read at each call, undefined off Wavedash
|
|
33
34
|
const wavedashSDK = ()=> globalThis['Wavedash'];
|
|
34
35
|
|
|
36
|
+
// the file a cloud save slot is kept in
|
|
37
|
+
const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json';
|
|
38
|
+
|
|
35
39
|
// a call's answer, or undefined if it does not come in time or fails, with a warning; the time limit is let go of once
|
|
36
40
|
// the answer is in, so nothing waits on it after
|
|
37
|
-
function wavedashWait(name, answer)
|
|
41
|
+
function wavedashWait(name, answer, quiet=false)
|
|
38
42
|
{
|
|
39
43
|
let timer;
|
|
40
|
-
const
|
|
44
|
+
const timedOut = {}; // what the time limit gives, told apart from any answer
|
|
45
|
+
const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS));
|
|
41
46
|
return Promise.race([Promise.resolve(answer), limit])
|
|
42
|
-
.
|
|
47
|
+
.then((value)=> value !== timedOut ? value : quiet ? undefined :
|
|
48
|
+
void console.warn('Wavedash ' + name + ' took over ' + wavedashTimeoutMS / 1e3 + ' seconds'),
|
|
49
|
+
(error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
|
|
43
50
|
.finally(()=> clearTimeout(timer));
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// a value as the text a cloud save keeps, undefined with a warning for one JSON can not hold, as a circular object,
|
|
54
|
+
// a BigInt, a function or undefined
|
|
55
|
+
function wavedashSaveText(slot, data)
|
|
56
|
+
{
|
|
57
|
+
let text;
|
|
58
|
+
try { text = JSON.stringify(data); } catch (error) {}
|
|
59
|
+
text === undefined && console.warn('Wavedash cloudSave: slot ' + slot + ' was given a value JSON can not hold');
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
///////////////////////////////////////////////////////////////////////////////
|
|
47
64
|
/**
|
|
48
65
|
* WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
|
|
@@ -121,6 +138,14 @@ class WavedashPlugin
|
|
|
121
138
|
this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
|
|
122
139
|
/** @type {number|undefined} */
|
|
123
140
|
this.achievementRetry = undefined; // the timer that sends refused ones again
|
|
141
|
+
/** @type {Promise<boolean>|undefined} */
|
|
142
|
+
this.stats = undefined; // the player's stats loading, asked for the first time a stat is
|
|
143
|
+
/** @type {Map<string, Promise<*>>} */
|
|
144
|
+
this.slotQueues = new Map; // each slot's last save or load, the next waits for it, as they share a file
|
|
145
|
+
/** @type {Map<string, Set<Promise<*>>>} */
|
|
146
|
+
this.slotCalls = new Map; // each slot's file calls Wavedash has not answered, a timed out one may still change it
|
|
147
|
+
/** @type {Map<string, 'failed'|'notSave'>} */
|
|
148
|
+
this.loadFailures = new Map; // why each slot's last load gave undefined, by its file, see loadFailure
|
|
124
149
|
|
|
125
150
|
// Wavedash keeps its loading screen until init, which is called once
|
|
126
151
|
wavedashWait('init', this.call('init'));
|
|
@@ -199,6 +224,212 @@ class WavedashPlugin
|
|
|
199
224
|
return result?.['success'] ? result['data'] : undefined;
|
|
200
225
|
}
|
|
201
226
|
|
|
227
|
+
/** Save a value to one of the player's cloud save slots, kept by Wavedash across devices; off Wavedash it does
|
|
228
|
+
* nothing
|
|
229
|
+
* - Any value JSON can hold, stored as the file saves/slot#.json
|
|
230
|
+
* - Wavedash takes at most 30 saves a minute and 300 an hour from a player, so save at checkpoints, the end of a
|
|
231
|
+
* level or when asked, not every frame; one over the limit is refused, said in the console
|
|
232
|
+
* @param {number} slot - The slot number, any whole number the game picks
|
|
233
|
+
* @param {*} data - The value to save
|
|
234
|
+
* @return {Promise<boolean>} - Whether it saved
|
|
235
|
+
* @example
|
|
236
|
+
* wavedash.cloudSave(1, {level, coins}); */
|
|
237
|
+
async cloudSave(slot, data)
|
|
238
|
+
{
|
|
239
|
+
ASSERT(isNumber(slot), 'Wavedash cloudSave: slot must be a number', slot);
|
|
240
|
+
if (!this.isActive()) return false;
|
|
241
|
+
const text = wavedashSaveText(slot, data); // as it is now, the save waits its turn
|
|
242
|
+
if (text === undefined) return false;
|
|
243
|
+
return this.slotQueue(slot, false, async (track)=>
|
|
244
|
+
{
|
|
245
|
+
// written first, and only a write Wavedash says it made is uploaded, never an older file at that path
|
|
246
|
+
const path = wavedashSavePath(slot);
|
|
247
|
+
const written = await wavedashWait('cloudSave', track(this.call('writeLocalFile', path,
|
|
248
|
+
new TextEncoder().encode(text))));
|
|
249
|
+
if (written !== true && !written?.['success'])
|
|
250
|
+
{
|
|
251
|
+
written && console.warn('Wavedash refused writing slot ' + slot + ': ' +
|
|
252
|
+
(written['message'] ?? written['error'] ?? 'no reason given'));
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const result = await wavedashWait('cloudSave', track(this.call('uploadRemoteFile', path)));
|
|
256
|
+
result && !result['success'] && console.warn('Wavedash refused cloudSave of slot ' + slot + ': ' +
|
|
257
|
+
(result['message'] ?? result['error'] ?? 'no reason given'));
|
|
258
|
+
return !!result?.['success'];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Load the value a cloud save slot holds: null when Wavedash says it holds none, undefined when it could not be
|
|
263
|
+
* loaded or off Wavedash, said in the console on Wavedash
|
|
264
|
+
* - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
|
|
265
|
+
* it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
|
|
266
|
+
* - A saved null loads as null, as an empty slot does
|
|
267
|
+
* @param {number} slot - The slot number
|
|
268
|
+
* @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
|
|
269
|
+
* @example
|
|
270
|
+
* const save = await wavedash.cloudLoad(1); */
|
|
271
|
+
async cloudLoad(slot)
|
|
272
|
+
{
|
|
273
|
+
ASSERT(isNumber(slot), 'Wavedash cloudLoad: slot must be a number', slot);
|
|
274
|
+
const failed = {value: undefined, failure: /** @type {'failed'} */ ('failed')};
|
|
275
|
+
const {value, failure} = this.isActive() ?
|
|
276
|
+
await this.slotQueue(slot, failed, (track)=> this.loadSlot(slot, track)) : failed;
|
|
277
|
+
const key = wavedashSavePath(slot);
|
|
278
|
+
failure ? this.loadFailures.set(key, failure) : this.loadFailures.delete(key);
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
|
|
283
|
+
* game may offer to start over, 'failed' when it could not be loaded, as off Wavedash or on a timeout, where a
|
|
284
|
+
* game must not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
|
|
285
|
+
* @param {number} slot - The slot number
|
|
286
|
+
* @return {'failed'|'notSave'|undefined} */
|
|
287
|
+
loadFailure(slot) { return this.loadFailures.get(wavedashSavePath(slot)); }
|
|
288
|
+
|
|
289
|
+
/** A slot's load, its value and why it failed, if it did
|
|
290
|
+
* @param {number} slot
|
|
291
|
+
* @param {function(*): *} track
|
|
292
|
+
* @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
|
|
293
|
+
* @ignore */
|
|
294
|
+
async loadSlot(slot, track)
|
|
295
|
+
{
|
|
296
|
+
const path = wavedashSavePath(slot);
|
|
297
|
+
const failed = (message)=>
|
|
298
|
+
{
|
|
299
|
+
console.warn('Wavedash ' + message);
|
|
300
|
+
return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
|
|
301
|
+
};
|
|
302
|
+
const downloaded = await wavedashWait('cloudLoad', track(this.call('downloadRemoteFile', path)));
|
|
303
|
+
if (downloaded !== true && !downloaded?.['success']) // an answer of true or of {success: true}
|
|
304
|
+
{
|
|
305
|
+
// a file Wavedash says is not there is an empty slot, anything else a load that failed; it may answer
|
|
306
|
+
// false or {success: true, data: false}
|
|
307
|
+
const exists = await wavedashWait('cloudLoad', track(this.call('remoteFileExists', path)));
|
|
308
|
+
if (exists === false || exists?.['success'] && exists['data'] === false)
|
|
309
|
+
return {value: null, failure: undefined};
|
|
310
|
+
return failed('could not load slot ' + slot);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// the file's text, its bytes given as they are or in a response's data; none is a read that failed, as a
|
|
314
|
+
// save is never empty
|
|
315
|
+
const answer = await wavedashWait('cloudLoad', track(this.call('readLocalFile', path)));
|
|
316
|
+
const bytes = answer instanceof Uint8Array || answer instanceof ArrayBuffer || typeof answer == 'string' ?
|
|
317
|
+
answer : answer?.['data'];
|
|
318
|
+
const text = !bytes ? '' : typeof bytes == 'string' ? bytes : new TextDecoder().decode(bytes);
|
|
319
|
+
if (!text) return failed('could not read slot ' + slot);
|
|
320
|
+
try { return {value: JSON.parse(text), failure: undefined}; }
|
|
321
|
+
catch (error)
|
|
322
|
+
{
|
|
323
|
+
console.warn('Wavedash cloudLoad: slot ' + slot + ' holds a file that is not a save');
|
|
324
|
+
return {value: undefined, failure: 'notSave'};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Run a slot's save or load after the one before it, as they share the slot's local file; other slots go on
|
|
329
|
+
* at once, and one that fails does not hold up the next
|
|
330
|
+
* - A call that timed out may still change the file when Wavedash gets to it, so the next waits for it to be
|
|
331
|
+
* answered too, and gives up with failed when it is not, leaving the file alone
|
|
332
|
+
* @param {number} slot
|
|
333
|
+
* @param {*} failed - What the task gives when it can not run
|
|
334
|
+
* @param {function(function(*): *): Promise<*>} task - Given track, which keeps a call's answer until it is in
|
|
335
|
+
* @return {Promise<*>}
|
|
336
|
+
* @ignore */
|
|
337
|
+
slotQueue(slot, failed, task)
|
|
338
|
+
{
|
|
339
|
+
const key = wavedashSavePath(slot); // by its file, so a slot given as '1' and as 1 share one
|
|
340
|
+
const calls = this.slotCalls.get(key) || new Set;
|
|
341
|
+
this.slotCalls.set(key, calls);
|
|
342
|
+
const track = (answer)=>
|
|
343
|
+
{
|
|
344
|
+
if (typeof answer?.then == 'function') // any promise, one made elsewhere too
|
|
345
|
+
{
|
|
346
|
+
calls.add(answer);
|
|
347
|
+
const done = ()=>
|
|
348
|
+
{
|
|
349
|
+
calls.delete(answer);
|
|
350
|
+
// the last of them, answered after the slot was let go of, lets go of its calls too
|
|
351
|
+
calls.size || this.slotQueues.has(key) || this.slotCalls.get(key) !== calls ||
|
|
352
|
+
this.slotCalls.delete(key);
|
|
353
|
+
};
|
|
354
|
+
answer.then(done, done);
|
|
355
|
+
}
|
|
356
|
+
return answer;
|
|
357
|
+
};
|
|
358
|
+
const start = async ()=>
|
|
359
|
+
{
|
|
360
|
+
if (calls.size && !await wavedashWait('slot ' + slot, Promise.allSettled([...calls]).then(()=> true), true))
|
|
361
|
+
{
|
|
362
|
+
console.warn('Wavedash slot ' + slot + ' is still busy with a call that timed out, not used');
|
|
363
|
+
return failed;
|
|
364
|
+
}
|
|
365
|
+
return task(track);
|
|
366
|
+
};
|
|
367
|
+
const run = (this.slotQueues.get(key) || Promise.resolve()).then(start, start);
|
|
368
|
+
const done = run.catch(()=> {});
|
|
369
|
+
this.slotQueues.set(key, done);
|
|
370
|
+
done.then(()=>
|
|
371
|
+
{
|
|
372
|
+
// the slot is let go of once nothing waits on it, and its calls with it once all are answered
|
|
373
|
+
if (this.slotQueues.get(key) !== done) return;
|
|
374
|
+
this.slotQueues.delete(key);
|
|
375
|
+
calls.size || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key);
|
|
376
|
+
});
|
|
377
|
+
return run;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Set one of the player's stats, as made for the game in the Wavedash developer portal, which can unlock an
|
|
381
|
+
* achievement set to follow it; Wavedash keeps it about a second later, or at once with storeNow; off Wavedash it
|
|
382
|
+
* does nothing
|
|
383
|
+
* @param {string} name - The stat's identifier
|
|
384
|
+
* @param {number} value
|
|
385
|
+
* @param {boolean} [storeNow] - Keep it now, as at the end of a game, not a second later
|
|
386
|
+
* @return {Promise<boolean>} - Whether Wavedash took it */
|
|
387
|
+
async setStat(name, value, storeNow=false)
|
|
388
|
+
{
|
|
389
|
+
ASSERT(typeof name === 'string' && name !== '', 'Wavedash setStat: name must be a string', name);
|
|
390
|
+
ASSERT(isNumber(value), 'Wavedash setStat: value must be a number', value);
|
|
391
|
+
if (!this.isActive() || !await this.statsLoaded()) return false;
|
|
392
|
+
return this.call('setStat', name, value, !!storeNow) === true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read one of the player's stats, 0 for one never set or off Wavedash
|
|
396
|
+
* @param {string} name - The stat's identifier
|
|
397
|
+
* @return {Promise<number>} */
|
|
398
|
+
async getStat(name)
|
|
399
|
+
{
|
|
400
|
+
if (!this.isActive() || !await this.statsLoaded()) return 0;
|
|
401
|
+
const value = this.call('getStat', name);
|
|
402
|
+
return isNumber(value) ? value : 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The player's stats loaded from Wavedash, which they must be before they are read or set, asked for the first
|
|
406
|
+
* time a stat is
|
|
407
|
+
* @return {Promise<boolean>} - Whether they loaded
|
|
408
|
+
* @ignore */
|
|
409
|
+
statsLoaded()
|
|
410
|
+
{
|
|
411
|
+
return this.stats ||= wavedashWait('requestStats', this.call('requestStats')).then((result)=>
|
|
412
|
+
{
|
|
413
|
+
const loaded = !!result?.['success'];
|
|
414
|
+
loaded || (this.stats = undefined); // asked for again next time
|
|
415
|
+
return loaded;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Set what the player is doing, kept with their presence on Wavedash; off Wavedash it does nothing
|
|
420
|
+
* @param {string} [status] - One line of what they are doing, like 'In a race'
|
|
421
|
+
* @param {string} [details] - More about it, like 'Lap 2 of 3'; both left out clears it
|
|
422
|
+
* @return {Promise<boolean>} - Whether it was set */
|
|
423
|
+
async setPresence(status, details)
|
|
424
|
+
{
|
|
425
|
+
if (!this.isActive()) return false;
|
|
426
|
+
const presence = {};
|
|
427
|
+
status === undefined || (presence['status'] = String(status));
|
|
428
|
+
details === undefined || (presence['details'] = String(details));
|
|
429
|
+
const result = await wavedashWait('setPresence', this.call('updateUserPresence', presence));
|
|
430
|
+
return !!result?.['success'];
|
|
431
|
+
}
|
|
432
|
+
|
|
202
433
|
/** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
|
|
203
434
|
* refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
|
|
204
435
|
* taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
|