littlejsengine 1.24.3 → 1.26.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 +123 -0
- package/EDITOR.md +2 -2
- package/FAQ.md +55 -11
- package/README.md +6 -2
- package/REFERENCE.md +203 -57
- package/dist/littlejs.d.ts +933 -313
- package/dist/littlejs.esm.js +5143 -2419
- package/dist/littlejs.esm.min.js +38 -24
- package/dist/littlejs.js +5132 -2416
- package/dist/littlejs.min.js +37 -23
- package/dist/littlejs.release.js +4905 -2232
- package/package.json +4 -4
- package/plugins/box2d.js +141 -56
- package/plugins/drawUtilities.js +374 -380
- package/plugins/gltf.js +383 -60
- package/plugins/lightSystem.js +412 -30
- package/plugins/math3d.js +1015 -1014
- package/plugins/medalSystem.js +19 -7
- package/plugins/newgrounds.js +5 -6
- package/plugins/parallax.js +1 -0
- package/plugins/particleEffects.js +47 -14
- package/plugins/pathFinder.js +60 -38
- package/plugins/pluginExport.js +11 -0
- package/plugins/postProcess.js +200 -67
- package/plugins/render3d.js +493 -2391
- package/plugins/render3dDebug.js +5 -8
- package/plugins/render3dEditor.js +84 -9
- package/plugins/render3dExtras.js +122 -33
- package/plugins/render3dLevel.js +67 -8
- package/plugins/render3dMesh.js +1158 -0
- package/plugins/render3dObject.js +1451 -0
- package/plugins/render3dVoxels.js +15 -3
- package/plugins/textureSheet.js +9 -8
- package/plugins/threejs.js +1 -1
- package/plugins/tweenSystem.js +117 -87
- package/plugins/uiSystem.js +62 -28
- package/plugins/wavedash.js +229 -0
- package/src/engine.js +192 -40
- package/src/engineAudio.js +47 -27
- package/src/engineBuild.mjs +6 -1
- package/src/engineDebug.js +23 -162
- package/src/engineDraw.js +148 -43
- package/src/engineEditor.js +153 -40
- package/src/engineExport.js +0 -3
- package/src/engineInput.js +31 -25
- package/src/engineMath.js +38 -20
- package/src/engineObject.js +68 -50
- package/src/engineParticles.js +66 -18
- package/src/engineRelease.js +18 -15
- package/src/engineSettings.js +48 -9
- package/src/engineTileLayer.js +44 -18
- package/src/engineUtilities.js +56 -11
- package/src/engineWebGL.js +68 -22
package/src/engineDebug.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - setDebugKeysAlways lets those keys work with the overlay closed too
|
|
7
7
|
* - ASSERT and LOG macros for development (removed in release builds)
|
|
8
8
|
* - Debug primitive rendering (rectangles, circles, lines, points, text)
|
|
9
|
-
* - Screenshot
|
|
9
|
+
* - Screenshot support
|
|
10
10
|
* - FPS counter and performance watermark
|
|
11
11
|
* - Debug overlay shows mouse position and picked objects
|
|
12
12
|
* @namespace Debug
|
|
@@ -84,7 +84,24 @@ function ASSERT(assert, ...output)
|
|
|
84
84
|
{
|
|
85
85
|
if (assert) return;
|
|
86
86
|
console.assert(assert, ...output)
|
|
87
|
-
|
|
87
|
+
// the error says what went wrong, so an error box or overlay that shows only the error shows the message too: a
|
|
88
|
+
// value with a toString of its own as that, a vec2 or a color, a plain object as JSON, an object of a class with
|
|
89
|
+
// none by its class's name, each cut at 200 characters, so a whole level is not the message
|
|
90
|
+
const text = output.map((value)=>
|
|
91
|
+
{
|
|
92
|
+
let text;
|
|
93
|
+
try
|
|
94
|
+
{
|
|
95
|
+
const prototype = value && typeof value === 'object' ? Object.getPrototypeOf(value) : undefined;
|
|
96
|
+
text = prototype === Object.prototype || prototype === null ? JSON.stringify(value) :
|
|
97
|
+
prototype && value.toString === Object.prototype.toString ?
|
|
98
|
+
'[' + (value.constructor?.name || 'object') + ']' : String(value);
|
|
99
|
+
}
|
|
100
|
+
catch { text = Object.prototype.toString.call(value); }
|
|
101
|
+
text = String(text); // JSON of nothing, as from a toJSON that returns nothing, is undefined
|
|
102
|
+
return text.length > 200 ? text.slice(0, 199) + '…' : text;
|
|
103
|
+
}).join(' ');
|
|
104
|
+
throw new Error(text ? 'Assert failed: ' + text : 'Assert failed!'); // halt execution
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
/** Log to console if debug is enabled, does nothing in release builds
|
|
@@ -94,7 +111,7 @@ function LOG(...output) { console.log(...output); }
|
|
|
94
111
|
|
|
95
112
|
/** Draw a debug rectangle in world space, or on the screen with screenSpace
|
|
96
113
|
* @param {Vector2} pos
|
|
97
|
-
* @param {Vector2} [size=vec2(
|
|
114
|
+
* @param {Vector2} [size=vec2()]
|
|
98
115
|
* @param {Color|string} [color]
|
|
99
116
|
* @param {number} [time]
|
|
100
117
|
* @param {number} [angle]
|
|
@@ -329,19 +346,6 @@ function debugUpdate()
|
|
|
329
346
|
if (keyWasPressed('Digit0'))
|
|
330
347
|
levelEditor.isOpen ? levelEditor.close() : levelEditor.open();
|
|
331
348
|
}
|
|
332
|
-
if (debugVideoCaptureIsActive())
|
|
333
|
-
{
|
|
334
|
-
// control to stop video capture, a capture the overlay started also stops when the overlay closes,
|
|
335
|
-
// one the game started from code runs until it calls debugVideoCaptureStop
|
|
336
|
-
if (debugKeys ? keyWasPressed('Digit8') : debugVideoCapture.fromOverlay)
|
|
337
|
-
debugVideoCaptureStop();
|
|
338
|
-
}
|
|
339
|
-
else if (debugKeys && keyWasPressed('Digit8'))
|
|
340
|
-
{
|
|
341
|
-
debugVideoCaptureStart();
|
|
342
|
-
if (debugVideoCapture)
|
|
343
|
-
debugVideoCapture.fromOverlay = true;
|
|
344
|
-
}
|
|
345
349
|
}
|
|
346
350
|
|
|
347
351
|
// the text beside the mouse, with the same shadow as the overlay
|
|
@@ -440,20 +444,13 @@ function debugRender()
|
|
|
440
444
|
{
|
|
441
445
|
if (debugTakeScreenshot)
|
|
442
446
|
{
|
|
443
|
-
// combine canvases, remove alpha and save
|
|
447
|
+
// combine canvases, remove alpha and save
|
|
444
448
|
glFlush();
|
|
445
449
|
combineCanvases();
|
|
446
450
|
saveCanvas(mainCanvas);
|
|
447
451
|
debugTakeScreenshot = 0;
|
|
448
452
|
}
|
|
449
453
|
|
|
450
|
-
if (debugVideoCaptureIsActive())
|
|
451
|
-
{
|
|
452
|
-
// don't show debug info when capturing video, but still drop the expired primitives so they don't pile up
|
|
453
|
-
debugPrimitives = debugPrimitives.filter(r=>r.timer<0);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
454
|
// flush any gl sprites before drawing debug info
|
|
458
455
|
glFlush();
|
|
459
456
|
|
|
@@ -682,7 +679,6 @@ function debugRender()
|
|
|
682
679
|
debugContext.fillText('6: Debug Sound', x, y += h);
|
|
683
680
|
debugContext.fillStyle = '#fff';
|
|
684
681
|
debugContext.fillText('7: Save Screenshot', x, y += h);
|
|
685
|
-
debugContext.fillText('8: Toggle Video Capture', x, y += h);
|
|
686
682
|
debugContext.fillStyle = debugTweakables ? '#f00' : '#fff';
|
|
687
683
|
debugContext.fillText('9: Tweakables', x, y += h);
|
|
688
684
|
debugContext.fillStyle = levelEditor.isOpen ? '#f00' : '#fff';
|
|
@@ -757,142 +753,6 @@ function debugRender()
|
|
|
757
753
|
}
|
|
758
754
|
}
|
|
759
755
|
|
|
760
|
-
function debugRenderPost() { debugVideoCaptureIsActive() && debugVideoCaptureUpdate(); }
|
|
761
|
-
|
|
762
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
763
|
-
// video capture - records video and audio at 60 fps using MediaRecorder API
|
|
764
|
-
|
|
765
|
-
// internal variables used to capture video
|
|
766
|
-
let debugVideoCapture, debugVideoCaptureIcon;
|
|
767
|
-
|
|
768
|
-
/** Check if video capture is active
|
|
769
|
-
* @memberof Debug */
|
|
770
|
-
function debugVideoCaptureIsActive() { return !!debugVideoCapture; }
|
|
771
|
-
|
|
772
|
-
/** Start capturing video
|
|
773
|
-
* @memberof Debug */
|
|
774
|
-
function debugVideoCaptureStart()
|
|
775
|
-
{
|
|
776
|
-
ASSERT(!debugVideoCaptureIsActive(), 'Already capturing video!');
|
|
777
|
-
|
|
778
|
-
// everything that can fail where recording is unsupported is in the try, a missing captureStream
|
|
779
|
-
// or a MediaRecorder that refuses webm throws here and is cleaned up rather than stopping the game
|
|
780
|
-
const captureTimer = new Timer(0, true);
|
|
781
|
-
const chunks = [];
|
|
782
|
-
let videoTrack, mediaRecorder, audioStreamDestination, silentAudioSource, audioTapNode;
|
|
783
|
-
try
|
|
784
|
-
{
|
|
785
|
-
// setup captureStream to capture manually by passing 0
|
|
786
|
-
const stream = mainCanvas.captureStream(0);
|
|
787
|
-
videoTrack = stream.getVideoTracks()[0];
|
|
788
|
-
videoTrack.applyConstraints({frameRate:frameRate}).catch(()=>{});
|
|
789
|
-
|
|
790
|
-
// set up the media recorder
|
|
791
|
-
mediaRecorder = new MediaRecorder(stream,
|
|
792
|
-
{mimeType:'video/webm;codecs=vp8'});
|
|
793
|
-
mediaRecorder.ondataavailable = (e)=> chunks.push(e.data);
|
|
794
|
-
mediaRecorder.onstop = ()=>
|
|
795
|
-
{
|
|
796
|
-
const blob = new Blob(chunks, {type: 'video/webm'});
|
|
797
|
-
const url = URL.createObjectURL(blob);
|
|
798
|
-
saveDataURL(url, 'capture.webm', 1e3);
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
if (soundEnable)
|
|
802
|
-
{
|
|
803
|
-
// create silent audio source
|
|
804
|
-
// fixes issue where video can not start recording without audio
|
|
805
|
-
silentAudioSource = new ConstantSourceNode(audioContext, { offset: 0 });
|
|
806
|
-
silentAudioSource.connect(audioMasterGain);
|
|
807
|
-
silentAudioSource.start();
|
|
808
|
-
|
|
809
|
-
// tap the end of the master chain so a master effect is in the recording
|
|
810
|
-
// (a master effect swapped mid-capture drops the tap, the rest records silent)
|
|
811
|
-
audioTapNode = audioMasterEffectOutput || audioMasterGain;
|
|
812
|
-
audioStreamDestination = audioContext.createMediaStreamDestination();
|
|
813
|
-
audioTapNode.connect(audioStreamDestination);
|
|
814
|
-
for (const track of audioStreamDestination.stream.getAudioTracks())
|
|
815
|
-
stream.addTrack(track); // add audio tracks to capture stream
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// start recording
|
|
819
|
-
mediaRecorder.start();
|
|
820
|
-
}
|
|
821
|
-
catch(e)
|
|
822
|
-
{
|
|
823
|
-
LOG('Video capture not supported in this browser!');
|
|
824
|
-
videoTrack?.stop();
|
|
825
|
-
silentAudioSource?.stop();
|
|
826
|
-
if (audioStreamDestination)
|
|
827
|
-
{
|
|
828
|
-
// the tap may not have been made before the failure
|
|
829
|
-
try { audioTapNode.disconnect(audioStreamDestination); }
|
|
830
|
-
catch { }
|
|
831
|
-
}
|
|
832
|
-
return;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
if (!debugVideoCaptureIcon)
|
|
836
|
-
{
|
|
837
|
-
// create recording icon to show it is capturing video
|
|
838
|
-
debugVideoCaptureIcon = document.createElement('div');
|
|
839
|
-
debugVideoCaptureIcon.style.position = 'absolute';
|
|
840
|
-
debugVideoCaptureIcon.style.padding = '9px';
|
|
841
|
-
debugVideoCaptureIcon.style.color = '#f00';
|
|
842
|
-
debugVideoCaptureIcon.style.font = '50px monospace';
|
|
843
|
-
document.body.appendChild(debugVideoCaptureIcon);
|
|
844
|
-
}
|
|
845
|
-
// show recording icon, only once recording has started
|
|
846
|
-
debugVideoCaptureIcon.textContent = '';
|
|
847
|
-
debugVideoCaptureIcon.style.display = '';
|
|
848
|
-
|
|
849
|
-
LOG('Video capture started.');
|
|
850
|
-
|
|
851
|
-
// save debug video info
|
|
852
|
-
debugVideoCapture =
|
|
853
|
-
{
|
|
854
|
-
mediaRecorder,
|
|
855
|
-
captureTimer,
|
|
856
|
-
videoTrack,
|
|
857
|
-
silentAudioSource,
|
|
858
|
-
audioStreamDestination,
|
|
859
|
-
audioTapNode
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/** Stop capturing video and save to disk
|
|
864
|
-
* @memberof Debug */
|
|
865
|
-
function debugVideoCaptureStop()
|
|
866
|
-
{
|
|
867
|
-
ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!');
|
|
868
|
-
|
|
869
|
-
// stop recording
|
|
870
|
-
LOG(`Video capture ended. ${debugVideoCapture.captureTimer.get().toFixed(2)} seconds recorded.`);
|
|
871
|
-
debugVideoCaptureIcon.style.display = 'none';
|
|
872
|
-
debugVideoCapture.silentAudioSource?.stop();
|
|
873
|
-
debugVideoCapture.mediaRecorder?.stop();
|
|
874
|
-
debugVideoCapture.videoTrack?.stop();
|
|
875
|
-
if (debugVideoCapture.audioStreamDestination)
|
|
876
|
-
{
|
|
877
|
-
// the tap is already gone if the master effect changed during the capture
|
|
878
|
-
try { debugVideoCapture.audioTapNode.disconnect(debugVideoCapture.audioStreamDestination); }
|
|
879
|
-
catch { }
|
|
880
|
-
}
|
|
881
|
-
debugVideoCapture = undefined;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// update video capture, called automatically by engine
|
|
885
|
-
function debugVideoCaptureUpdate()
|
|
886
|
-
{
|
|
887
|
-
ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!');
|
|
888
|
-
|
|
889
|
-
// save the video frame
|
|
890
|
-
combineCanvases();
|
|
891
|
-
debugVideoCapture.videoTrack.requestFrame();
|
|
892
|
-
debugVideoCaptureIcon.textContent = '● REC '
|
|
893
|
-
+ formatTime(debugVideoCapture.captureTimer.get());
|
|
894
|
-
}
|
|
895
|
-
|
|
896
756
|
///////////////////////////////////////////////////////////////////////////////
|
|
897
757
|
// debug utility functions
|
|
898
758
|
|
|
@@ -912,7 +772,8 @@ function debugProtectConstant(obj)
|
|
|
912
772
|
get: ()=> values[prop],
|
|
913
773
|
set: (value)=>
|
|
914
774
|
{
|
|
915
|
-
ASSERT(false, `
|
|
775
|
+
ASSERT(false, `engine constants like RED can not be changed, change a copy made with copy(); ` +
|
|
776
|
+
`tried to set ${prop} of ${obj} to ${value}`);
|
|
916
777
|
},
|
|
917
778
|
enumerable: true
|
|
918
779
|
});
|
package/src/engineDraw.js
CHANGED
|
@@ -105,7 +105,7 @@ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
|
|
|
105
105
|
* Create a tile info object using a grid based system
|
|
106
106
|
* - This can take vecs or floats for easier use and conversion
|
|
107
107
|
* - If an index is passed in, the tile size and index will determine the position
|
|
108
|
-
* @param {Vector2|number} [index
|
|
108
|
+
* @param {Vector2|number} [index] - Index of the tile in 1d or 2d form
|
|
109
109
|
* @param {Vector2|number} [size] - Size of tile in pixels
|
|
110
110
|
* @param {TextureInfo|number} [texture] - Texture index or info to use
|
|
111
111
|
* @param {number} [padding] - How many pixels padding around tiles
|
|
@@ -127,7 +127,7 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
|
|
|
127
127
|
if (typeof size === 'number')
|
|
128
128
|
{
|
|
129
129
|
// if size is a number, make it a vector
|
|
130
|
-
ASSERT(size > 0);
|
|
130
|
+
ASSERT(size > 0, 'tile: size must be above 0', size);
|
|
131
131
|
size = new Vector2(size, size);
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -138,7 +138,8 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
|
|
|
138
138
|
{
|
|
139
139
|
// no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives
|
|
140
140
|
// a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out
|
|
141
|
-
ASSERT(headlessMode || textureInfo instanceof TextureInfo,
|
|
141
|
+
ASSERT(headlessMode || textureInfo instanceof TextureInfo,
|
|
142
|
+
'tile: no texture ' + texture + ', pass its image to engineInit and make tiles in gameInit or later');
|
|
142
143
|
return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed);
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -209,7 +210,7 @@ class TileInfo
|
|
|
209
210
|
*/
|
|
210
211
|
frame(frame)
|
|
211
212
|
{
|
|
212
|
-
ASSERT(typeof frame === 'number');
|
|
213
|
+
ASSERT(typeof frame === 'number', 'TileInfo.frame: frame must be a number', frame);
|
|
213
214
|
const w = this.size.x + this.padding*2;
|
|
214
215
|
const h = this.size.y + this.padding*2;
|
|
215
216
|
const x = (this.columns ? frame % this.columns : frame) * w;
|
|
@@ -578,6 +579,12 @@ class Shader
|
|
|
578
579
|
this.program3D = undefined;
|
|
579
580
|
glShaderObjects.push(this); // a lost context drops the programs of every one
|
|
580
581
|
}
|
|
582
|
+
|
|
583
|
+
/** Let go of this shader: its compiled programs are freed and it leaves the engine's list of shaders, which
|
|
584
|
+
* keeps every one made for a lost context, so a scene made again and again, or an editor trying snippets, does
|
|
585
|
+
* not keep them all; calling it again does nothing, and a draw with it afterwards compiles it again and takes it
|
|
586
|
+
* back, so only let go of one no object still draws with when it should stay freed */
|
|
587
|
+
dispose() { glShaderDispose(this); }
|
|
581
588
|
}
|
|
582
589
|
|
|
583
590
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -589,10 +596,10 @@ class Shader
|
|
|
589
596
|
* @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
|
|
590
597
|
* @param {Color} [color=WHITE] - Color to modulate with
|
|
591
598
|
* @param {number} [angle] - Angle to rotate by
|
|
592
|
-
* @param {boolean} [mirror] - Is image flipped
|
|
599
|
+
* @param {boolean} [mirror] - Is the image flipped left to right?
|
|
593
600
|
* @param {Color} [additiveColor] - Additive color to be applied if any
|
|
594
601
|
* @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
|
|
595
|
-
* @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size
|
|
602
|
+
* @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size in screen space?
|
|
596
603
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
|
|
597
604
|
* @memberof Draw */
|
|
598
605
|
function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
@@ -600,6 +607,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
600
607
|
{
|
|
601
608
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
602
609
|
ASSERT(isVector2(size), 'size must be a vec2');
|
|
610
|
+
ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'drawTile: tileInfo must be a TileInfo, color comes after it',
|
|
611
|
+
tileInfo);
|
|
603
612
|
ASSERT(isColor(color), 'color is invalid');
|
|
604
613
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
605
614
|
ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
|
|
@@ -663,6 +672,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
663
672
|
{
|
|
664
673
|
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
665
674
|
context.scale(1, -1);
|
|
675
|
+
// smooth or pixelated as the texture says, as WebGL draws it, inside drawCanvas2D's save
|
|
676
|
+
textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
|
|
666
677
|
// calculate uvs and render
|
|
667
678
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
668
679
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -762,9 +773,9 @@ function drawRectGradient(pos, size=vec2(1), colorTop=WHITE, colorBottom=CLEAR_W
|
|
|
762
773
|
* @param {Vector2} pos - Center of the rect in world space
|
|
763
774
|
* @param {Vector2} size - Size of the rect in world space
|
|
764
775
|
* @param {Vector2} wrapCount - How many times the texture repeats (x, y)
|
|
765
|
-
* @param {TextureInfo|number} [texture
|
|
776
|
+
* @param {TextureInfo|number} [texture] - TextureInfo or texture index into textureInfos
|
|
766
777
|
* @param {Color} [color=WHITE] - Color to modulate with
|
|
767
|
-
* @param {number} [angle
|
|
778
|
+
* @param {number} [angle] - Angle to rotate by
|
|
768
779
|
* @param {Color} [additiveColor] - Additive color to be applied if any
|
|
769
780
|
* @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
|
|
770
781
|
* @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
|
|
@@ -826,12 +837,29 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
826
837
|
// alpha is baked into pixels by bakeTintedImage's additive branch;
|
|
827
838
|
// in that case globalAlpha must NOT also apply color.a
|
|
828
839
|
const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
840
|
+
let source = textureInfo.image;
|
|
841
|
+
if (!noTint)
|
|
842
|
+
{
|
|
843
|
+
// a bake is a pass over every pixel, so it is kept for the image until its tint changes; a canvas, which has
|
|
844
|
+
// getContext, may have been drawn into since, so it is baked again at every draw, its copy kept at its size
|
|
845
|
+
const key = color.r + ',' + color.g + ',' + color.b + ',' + color.a +
|
|
846
|
+
(additiveColor ? ',' + additiveColor.r + ',' + additiveColor.g + ',' + additiveColor.b + ',' + additiveColor.a : '');
|
|
847
|
+
let baked = drawTextureWrappedBakes.get(source);
|
|
848
|
+
if (baked?.key !== key || 'getContext' in source)
|
|
849
|
+
{
|
|
850
|
+
const kept = baked?.context, fits = kept?.canvas.width === (source.width|0) &&
|
|
851
|
+
kept?.canvas.height === (source.height|0);
|
|
852
|
+
const bakeContext = fits ? kept : createCanvasContext(source.width|0, source.height|0, true);
|
|
853
|
+
bakeTintedImage(source, color, additiveColor, bakeContext);
|
|
854
|
+
drawTextureWrappedBakes.set(source, baked = {key, context: bakeContext});
|
|
855
|
+
}
|
|
856
|
+
source = baked.context.canvas;
|
|
857
|
+
}
|
|
832
858
|
|
|
833
859
|
context = context || drawContext;
|
|
834
860
|
context.save();
|
|
861
|
+
// smooth or pixelated as the texture says, as WebGL draws it
|
|
862
|
+
textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
|
|
835
863
|
context.translate(pos.x + .5, pos.y + .5);
|
|
836
864
|
context.rotate(angle);
|
|
837
865
|
context.globalAlpha = alphaBaked ? 1 : color.a;
|
|
@@ -1106,9 +1134,9 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
|
|
|
1106
1134
|
|
|
1107
1135
|
/** Draw colored circle using passed in point
|
|
1108
1136
|
* @param {Vector2} pos
|
|
1109
|
-
* @param {number} [size
|
|
1137
|
+
* @param {number} [size] - Diameter
|
|
1110
1138
|
* @param {Color} [color=WHITE]
|
|
1111
|
-
* @param {number} [lineWidth
|
|
1139
|
+
* @param {number} [lineWidth]
|
|
1112
1140
|
* @param {Color} [lineColor=BLACK]
|
|
1113
1141
|
* @param {boolean} [useWebGL=glEnable]
|
|
1114
1142
|
* @param {boolean} [screenSpace=drawScreenSpace]
|
|
@@ -1203,7 +1231,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
|
|
|
1203
1231
|
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
|
|
1204
1232
|
* - Stacking gradients at the exact same position may show a faint vertical artifact
|
|
1205
1233
|
* @param {Vector2} pos
|
|
1206
|
-
* @param {number} [size
|
|
1234
|
+
* @param {number} [size] - Diameter
|
|
1207
1235
|
* @param {Color} [colorInner=WHITE]
|
|
1208
1236
|
* @param {Color} [colorOuter=CLEAR_WHITE]
|
|
1209
1237
|
* @param {boolean} [useWebGL=glEnable]
|
|
@@ -1268,7 +1296,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
|
|
|
1268
1296
|
* @param {Color} [color=WHITE]
|
|
1269
1297
|
* @param {number} [lineWidth]
|
|
1270
1298
|
* @param {Color} [lineColor=BLACK]
|
|
1271
|
-
* @param {'left'|'center'|'right'} [textAlign
|
|
1299
|
+
* @param {'left'|'center'|'right'} [textAlign]
|
|
1272
1300
|
* @param {string} [font=fontDefault]
|
|
1273
1301
|
* @param {string} [fontStyle]
|
|
1274
1302
|
* @param {number} [maxWidth]
|
|
@@ -1277,6 +1305,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
|
|
|
1277
1305
|
* @memberof Draw */
|
|
1278
1306
|
function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
|
|
1279
1307
|
{
|
|
1308
|
+
// checked before it is scaled, a vec2 size would come out NaN
|
|
1309
|
+
ASSERT(isVector2(pos), 'drawText: pos must be a vec2', pos);
|
|
1310
|
+
ASSERT(isNumber(size), 'drawText: size is a number, the height of the text in world units', size);
|
|
1311
|
+
|
|
1280
1312
|
// convert to screen space
|
|
1281
1313
|
pos = worldToScreen(pos);
|
|
1282
1314
|
size *= cameraScale;
|
|
@@ -1288,6 +1320,73 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
|
|
|
1288
1320
|
drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
|
|
1289
1321
|
}
|
|
1290
1322
|
|
|
1323
|
+
// the fonts a debug build has checked the canvas takes, by style and family, as a size never decides it and a zooming
|
|
1324
|
+
// camera draws at a new size each frame, and a font it does not, which would draw as 10px sans-serif with no word,
|
|
1325
|
+
// warned of once; a family name with spaces and digits, as Press Start 2P, needs quotes
|
|
1326
|
+
const drawFontsChecked = new Set;
|
|
1327
|
+
function drawFontCheck(context, fontText, font, fontStyle)
|
|
1328
|
+
{
|
|
1329
|
+
const key = fontStyle + '|' + font;
|
|
1330
|
+
if (drawFontsChecked.has(key)) return;
|
|
1331
|
+
drawFontsChecked.add(key);
|
|
1332
|
+
const sentinel = '1px littlejs-font-check'; // no font a game draws with
|
|
1333
|
+
context.font = sentinel;
|
|
1334
|
+
context.font = fontText;
|
|
1335
|
+
if (context.font === sentinel)
|
|
1336
|
+
console.warn(`the canvas does not take the font ${font}, so text draws as 10px sans-serif; ` +
|
|
1337
|
+
`a family name with spaces or digits goes in quotes, as "'Press Start 2P'"`);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// the lines of a text, split at a Windows line ending too
|
|
1341
|
+
const textLines = (text)=> (text += '').includes('\r') ? text.split(/\r?\n/) : text.split('\n');
|
|
1342
|
+
|
|
1343
|
+
// how many lines a text has, counted without splitting it
|
|
1344
|
+
function textLineCount(text)
|
|
1345
|
+
{
|
|
1346
|
+
let count = 1;
|
|
1347
|
+
for (let i = -1; (i = text.indexOf('\n', i + 1)) >= 0;)
|
|
1348
|
+
++count;
|
|
1349
|
+
return count;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// the characters of a line of text as a reader counts them, each as its first code point: an emoji with its joiners
|
|
1353
|
+
// is one, and a letter with combining accents its letter; plain text takes the quick way, it is most of what is drawn,
|
|
1354
|
+
// so read character i with textCharacterCode
|
|
1355
|
+
const textIntl = /** @type {any} */ (globalThis.Intl); // Segmenter is newer than the library the source is checked with
|
|
1356
|
+
const textSegmenter = textIntl?.Segmenter && new textIntl.Segmenter;
|
|
1357
|
+
// a line with no mark, joiner or character past the 16 bit range, plain or accented letters of their own, Greek or
|
|
1358
|
+
// Japanese, has one code unit a character, so it is handed back as it is, read with charCodeAt, and nothing is made;
|
|
1359
|
+
// the class also holds what the segmenter joins that is not a mark: joiners, prepend, Thai and Lao SARA AM, Hangul
|
|
1360
|
+
// jamo extended and the halfwidth katakana voiced marks
|
|
1361
|
+
const textJoins = /[\p{M}\u200c\u200d\u0600-\u0605\u06dd\u070f\u0890\u0891\u08e2\u0d4e\u0e33\u0eb3\u1100-\u11ff\ua960-\ua97c\ud7b0-\ud7fb\uff9e\uff9f\u{10000}-\u{10ffff}]/u;
|
|
1362
|
+
/** @return {string|Array<number>} */
|
|
1363
|
+
function textCharacters(line)
|
|
1364
|
+
{
|
|
1365
|
+
if (!textJoins.test(line))
|
|
1366
|
+
return line;
|
|
1367
|
+
// where graphemes can not be split, code points with the joiners, variation selectors and accents left out
|
|
1368
|
+
return textSegmenter ? Array.from(textSegmenter.segment(line), (s)=> s.segment.codePointAt(0)) :
|
|
1369
|
+
Array.from(line, (c)=> c.codePointAt(0)).filter((c)=> c !== 0x200d && c !== 0xfe0f && !(c >= 0x300 && c < 0x370));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// the characters of a text as a reader counts them, each a string, an emoji with its joiners one; code points where
|
|
1373
|
+
// graphemes can not be split, and code units for a text that needs nothing joined, the quick way
|
|
1374
|
+
const textGraphemes = (text)=> !textJoins.test(text) ? text.split('') :
|
|
1375
|
+
textSegmenter ? Array.from(textSegmenter.segment(text), (s)=> s.segment) : [...text];
|
|
1376
|
+
|
|
1377
|
+
// character i of what textCharacters gave, its code; a Latin letter with an accent of its own, as é, is its letter, as
|
|
1378
|
+
// an e with a combining accent is, for the fonts that draw only the plain letters
|
|
1379
|
+
const textCharacterCode = (characters, i)=>
|
|
1380
|
+
textBaseLetter(typeof characters == 'string' ? characters.charCodeAt(i) : characters[i]);
|
|
1381
|
+
const textBaseLetters = new Map;
|
|
1382
|
+
function textBaseLetter(code)
|
|
1383
|
+
{
|
|
1384
|
+
if (code < 0xc0 || code >= 0x250) return code;
|
|
1385
|
+
let base = textBaseLetters.get(code);
|
|
1386
|
+
base === undefined && textBaseLetters.set(code, base = String.fromCharCode(code).normalize('NFD').charCodeAt(0));
|
|
1387
|
+
return base;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1291
1390
|
/** Draw text in screen space
|
|
1292
1391
|
* Automatically splits new lines into rows
|
|
1293
1392
|
* @param {string|number} text
|
|
@@ -1305,27 +1404,29 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
|
|
|
1305
1404
|
* @memberof Draw */
|
|
1306
1405
|
function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
|
|
1307
1406
|
{
|
|
1308
|
-
ASSERT(isStringLike(text), 'text must be a string');
|
|
1309
|
-
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
1310
|
-
ASSERT(isNumber(size), 'size
|
|
1311
|
-
ASSERT(isColor(color), 'color must be a color');
|
|
1312
|
-
ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
|
|
1313
|
-
ASSERT(isColor(lineColor), 'lineColor must be a color');
|
|
1314
|
-
ASSERT(['left','center','right'].includes(textAlign), '
|
|
1407
|
+
ASSERT(isStringLike(text), 'drawTextScreen: text must be a string', text);
|
|
1408
|
+
ASSERT(isVector2(pos), 'drawTextScreen: pos must be a vec2', pos);
|
|
1409
|
+
ASSERT(isNumber(size), 'drawTextScreen: size is a number, the height of the text in pixels', size);
|
|
1410
|
+
ASSERT(isColor(color), 'drawTextScreen: color must be a color', color);
|
|
1411
|
+
ASSERT(isNumber(lineWidth), 'drawTextScreen: lineWidth must be a number', lineWidth);
|
|
1412
|
+
ASSERT(isColor(lineColor), 'drawTextScreen: lineColor must be a color', lineColor);
|
|
1413
|
+
ASSERT(['left','center','right'].includes(textAlign), 'drawTextScreen: textAlign must be left, center or right', textAlign);
|
|
1315
1414
|
ASSERT(isStringLike(font), 'font must be a string');
|
|
1316
1415
|
ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
|
|
1317
1416
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
1318
1417
|
|
|
1319
1418
|
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
|
|
1320
1419
|
|
|
1321
|
-
const lines = (text
|
|
1420
|
+
const lines = textLines(text); // a Windows line ending too
|
|
1322
1421
|
// save before style mutations so caller's context state is preserved
|
|
1323
1422
|
context.save();
|
|
1324
1423
|
context.fillStyle = color.toString();
|
|
1325
1424
|
context.strokeStyle = lineColor.toString();
|
|
1326
1425
|
context.lineWidth = lineWidth;
|
|
1327
1426
|
context.textAlign = textAlign;
|
|
1328
|
-
|
|
1427
|
+
const fontText = fontStyle + ' ' + size + 'px '+ font;
|
|
1428
|
+
debug && drawFontCheck(context, fontText, font, fontStyle);
|
|
1429
|
+
context.font = fontText;
|
|
1329
1430
|
context.textBaseline = 'middle';
|
|
1330
1431
|
context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
|
|
1331
1432
|
context.rotate(angle);
|
|
@@ -1609,7 +1710,7 @@ function setShader(shader)
|
|
|
1609
1710
|
|
|
1610
1711
|
/** Set an extra canvas to composite behind the engine canvases when combining
|
|
1611
1712
|
* Plugins that insert their own canvas below the LittleJS canvases should set
|
|
1612
|
-
* this so it appears in screenshots
|
|
1713
|
+
* this so it appears in screenshots
|
|
1613
1714
|
* @param {HTMLCanvasElement} [canvas]
|
|
1614
1715
|
* @memberof Draw */
|
|
1615
1716
|
function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
|
|
@@ -1665,24 +1766,27 @@ function tintImageData(data, color, additiveColor)
|
|
|
1665
1766
|
return false;
|
|
1666
1767
|
}
|
|
1667
1768
|
|
|
1668
|
-
// Internal: bake a color/additive-color tint into
|
|
1669
|
-
// image's native resolution. Returns
|
|
1670
|
-
// passing to context.createPattern. Used by drawTextureWrapped's
|
|
1671
|
-
// Canvas2D path. Caller is responsible for short-circuiting when no
|
|
1769
|
+
// Internal: bake a color/additive-color tint into a context's canvas, the work
|
|
1770
|
+
// canvas by default, at the image's native resolution. Returns that canvas,
|
|
1771
|
+
// suitable for passing to context.createPattern. Used by drawTextureWrapped's
|
|
1772
|
+
// Canvas2D path, which keeps a canvas of its own for each image. Caller is responsible for short-circuiting when no
|
|
1672
1773
|
// tint is needed (i.e. color is white and additiveColor is black/none).
|
|
1673
|
-
function bakeTintedImage(image, color, additiveColor)
|
|
1774
|
+
function bakeTintedImage(image, color, additiveColor, context=workReadContext)
|
|
1674
1775
|
{
|
|
1675
1776
|
const w = image.width|0, h = image.height|0;
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1777
|
+
context.canvas.width = w;
|
|
1778
|
+
context.canvas.height = h;
|
|
1779
|
+
context.drawImage(image, 0, 0);
|
|
1679
1780
|
|
|
1680
|
-
const imageData =
|
|
1781
|
+
const imageData = context.getImageData(0, 0, w, h);
|
|
1681
1782
|
tintImageData(imageData.data, color, additiveColor);
|
|
1682
|
-
|
|
1683
|
-
return
|
|
1783
|
+
context.putImageData(imageData, 0, 0);
|
|
1784
|
+
return context.canvas;
|
|
1684
1785
|
}
|
|
1685
1786
|
|
|
1787
|
+
// the tinted bake drawTextureWrapped keeps for each image, its tint and the context it is in
|
|
1788
|
+
const drawTextureWrappedBakes = new WeakMap;
|
|
1789
|
+
|
|
1686
1790
|
/** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
|
|
1687
1791
|
* This is slower than normal drawImage when color is applied
|
|
1688
1792
|
* @ignore
|
|
@@ -1748,10 +1852,10 @@ function toggleFullscreen()
|
|
|
1748
1852
|
if (isFullscreen())
|
|
1749
1853
|
{
|
|
1750
1854
|
if (document.exitFullscreen)
|
|
1751
|
-
document.exitFullscreen();
|
|
1855
|
+
document.exitFullscreen()?.catch?.(()=> {}); // refused outside a click, it stays as it is
|
|
1752
1856
|
}
|
|
1753
1857
|
else if (rootElement.requestFullscreen)
|
|
1754
|
-
rootElement.requestFullscreen();
|
|
1858
|
+
rootElement.requestFullscreen()?.catch?.(()=> {});
|
|
1755
1859
|
}
|
|
1756
1860
|
|
|
1757
1861
|
/** Set the cursor style
|
|
@@ -1815,7 +1919,7 @@ class ImageFont
|
|
|
1815
1919
|
if (typeof size === 'number')
|
|
1816
1920
|
{
|
|
1817
1921
|
// if size is a number, make it a vector
|
|
1818
|
-
ASSERT(size > 0);
|
|
1922
|
+
ASSERT(size > 0, 'ImageFont.drawText: size must be above 0', size);
|
|
1819
1923
|
size *= cameraScale;
|
|
1820
1924
|
size = new Vector2(size, size);
|
|
1821
1925
|
}
|
|
@@ -1853,15 +1957,16 @@ class ImageFont
|
|
|
1853
1957
|
const tileInfo = this.tileInfo.frame(0);
|
|
1854
1958
|
|
|
1855
1959
|
// draw each line of text, centered vertically like drawTextScreen when center is set
|
|
1856
|
-
const lines = (text
|
|
1960
|
+
const lines = textLines(text); // a Windows line ending too
|
|
1857
1961
|
const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
|
|
1858
1962
|
lines.forEach((line, j)=>
|
|
1859
1963
|
{
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1964
|
+
const characters = textCharacters(line);
|
|
1965
|
+
const centerOffset = center ? (characters.length-1) * glyphSize.x / 2 : 0;
|
|
1966
|
+
for (let i=characters.length; i--;)
|
|
1862
1967
|
{
|
|
1863
1968
|
// get the glyph, out of range characters use the last one
|
|
1864
|
-
const charCode =
|
|
1969
|
+
const charCode = textCharacterCode(characters, i);
|
|
1865
1970
|
this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
|
|
1866
1971
|
|
|
1867
1972
|
// snap the glyph edges to whole pixels
|