littlejsengine 1.18.28 → 1.19.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.18.28",
3
+ "version": "1.19.3",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -0,0 +1,371 @@
1
+ /**
2
+ * LittleJS Audio Effects Plugin
3
+ * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
4
+ * - Route a sound through one with sound.output = effect
5
+ * - Route everything with setAudioMasterEffect(effect)
6
+ * - Chain effects with effect.connect(nextEffect)
7
+ * @namespace AudioEffects
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ // ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight
15
+ function audioParamRamp(param, value, fadeTime=0)
16
+ {
17
+ ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
18
+ const startTime = audioContext.currentTime;
19
+ param.cancelScheduledValues(startTime);
20
+ if (fadeTime)
21
+ {
22
+ param.setValueAtTime(param.value, startTime);
23
+ param.linearRampToValueAtTime(value, startTime + fadeTime);
24
+ }
25
+ else
26
+ param.value = value;
27
+ }
28
+
29
+ ///////////////////////////////////////////////////////////////////////////////
30
+ /**
31
+ * Base class for audio effects, an input and output with a wet/dry mix between them
32
+ * - Sounds connect to input, output goes to the master gain until connect() moves it
33
+ * - Subclasses put their nodes between input and the wet gain with connectEffect
34
+ * @memberof AudioEffects
35
+ * @example
36
+ * const cave = new AudioReverb(3, 2);
37
+ * footstep.output = cave; // every play of this sound is in the cave
38
+ */
39
+ class AudioEffect
40
+ {
41
+ /** Create an audio effect
42
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
43
+ constructor(mix=1)
44
+ {
45
+ ASSERT(isNumber(mix), 'mix must be a number');
46
+
47
+ /** @property {GainNode} - Connect sounds to this node */
48
+ this.input = audioContext.createGain();
49
+ /** @property {GainNode} - This node carries the mixed result, send it somewhere with connect(), never by assigning here
50
+ * - Unlike sound.output, which is where a sound's audio goes and can be set to an effect */
51
+ this.output = audioContext.createGain();
52
+ /** @property {GainNode} - Level of the unprocessed signal */
53
+ this.dryGain = audioContext.createGain();
54
+ /** @property {GainNode} - Level of the processed signal */
55
+ this.wetGain = audioContext.createGain();
56
+ /** @property {number} - Wet/dry balance, 0 is fully dry and 1 is fully wet */
57
+ this.mix = mix;
58
+
59
+ this.input.connect(this.dryGain).connect(this.output);
60
+ this.wetGain.connect(this.output);
61
+ this.setMix(mix);
62
+
63
+ // send the result to the speakers, connect() moves it into a chain instead
64
+ this.output.connect(audioMasterGain);
65
+ }
66
+
67
+ /** Set the wet/dry balance
68
+ * @param {number} mix - 0 is fully dry and 1 is fully wet
69
+ * @param {number} [fadeTime] - Seconds to ramp over so the change doesn't click */
70
+ setMix(mix, fadeTime=0)
71
+ {
72
+ ASSERT(isNumber(mix), 'mix must be a number');
73
+ this.mix = mix = clamp(mix);
74
+ this.rampParam(this.dryGain.gain, 1-mix, fadeTime);
75
+ this.rampParam(this.wetGain.gain, mix, fadeTime);
76
+ }
77
+
78
+ /** Ramp one of this effect's params, keeping the effect running until the ramp is done
79
+ * - The browser drops an effect from rendering while nothing plays through it, which
80
+ * would freeze a ramp partway, so a silent source feeds the input for the ramp's length
81
+ * @param {AudioParam} param - The param to ramp
82
+ * @param {number} value - Where to ramp to
83
+ * @param {number} [fadeTime] - Seconds to ramp over, 0 sets the value at once
84
+ * @protected */
85
+ rampParam(param, value, fadeTime=0)
86
+ {
87
+ audioParamRamp(param, value, fadeTime);
88
+ if (!fadeTime) return;
89
+ const keepAlive = new ConstantSourceNode(audioContext, { offset: 0 });
90
+ keepAlive.connect(this.input);
91
+ keepAlive.onended = ()=> keepAlive.disconnect();
92
+ keepAlive.start();
93
+ keepAlive.stop(audioContext.currentTime + fadeTime);
94
+ }
95
+
96
+ /** Send this effect's output into another effect or audio node instead of the speakers
97
+ * @param {AudioEffect|AudioNode} target - The next effect in the chain, or any audio node
98
+ * @return {AudioEffect|AudioNode} - The target, so chains read left to right */
99
+ connect(target)
100
+ {
101
+ // an effect stands in for its input node, the same rule as sound.output
102
+ const node = /** @type {AudioNode} */ (target && 'input' in target ? target.input : target);
103
+ ASSERT(node && typeof node.connect === 'function', 'target must be an AudioEffect or AudioNode');
104
+ this.output.disconnect();
105
+ this.output.connect(node);
106
+ return target;
107
+ }
108
+
109
+ /** Stop sending this effect's output anywhere */
110
+ disconnect() { this.output.disconnect(); }
111
+
112
+ /** Wire nodes between the input and the wet gain, for subclasses
113
+ * @param {AudioNode} first - Node the input connects to
114
+ * @param {AudioNode} [last=first] - Node that connects to the wet gain
115
+ * @protected */
116
+ connectEffect(first, last=first)
117
+ {
118
+ this.input.connect(first);
119
+ last.connect(this.wetGain);
120
+ }
121
+ }
122
+
123
+ ///////////////////////////////////////////////////////////////////////////////
124
+ /**
125
+ * Filter effect, muffle sounds underwater or behind a wall
126
+ * @extends AudioEffect
127
+ * @memberof AudioEffects
128
+ * @example
129
+ * const muffle = new AudioFilter('lowpass', 400);
130
+ * setAudioMasterEffect(muffle);
131
+ * muffle.setFrequency(20000, .5); // sweep back to clear
132
+ */
133
+ class AudioFilter extends AudioEffect
134
+ {
135
+ /** Create a filter effect
136
+ * @param {BiquadFilterType} [type] - lowpass, highpass, bandpass, notch, etc.
137
+ * @param {number} [frequency] - Cutoff or center frequency in Hz
138
+ * @param {number} [q] - Resonance at the cutoff, higher is sharper
139
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
140
+ constructor(type='lowpass', frequency=1000, q=1, mix=1)
141
+ {
142
+ super(mix);
143
+ ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero');
144
+ ASSERT(isNumber(q), 'q must be a number');
145
+
146
+ /** @property {BiquadFilterNode} - The filter node */
147
+ this.node = audioContext.createBiquadFilter();
148
+ this.node.type = type;
149
+ this.node.frequency.value = frequency;
150
+ this.node.Q.value = q;
151
+ this.connectEffect(this.node);
152
+ }
153
+
154
+ /** Set the cutoff or center frequency
155
+ * @param {number} frequency - Frequency in Hz
156
+ * @param {number} [fadeTime] - Seconds to sweep over */
157
+ setFrequency(frequency, fadeTime=0)
158
+ {
159
+ ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero');
160
+ this.rampParam(this.node.frequency, frequency, fadeTime);
161
+ }
162
+
163
+ /** Set the resonance at the cutoff
164
+ * @param {number} q - Higher is sharper
165
+ * @param {number} [fadeTime] - Seconds to ramp over */
166
+ setQ(q, fadeTime=0)
167
+ {
168
+ ASSERT(isNumber(q), 'q must be a number');
169
+ this.rampParam(this.node.Q, q, fadeTime);
170
+ }
171
+ }
172
+
173
+ ///////////////////////////////////////////////////////////////////////////////
174
+ /**
175
+ * Reverb effect, puts sounds in a room, cave, or hall
176
+ * - The impulse response is generated, no audio file needed
177
+ * @extends AudioEffect
178
+ * @memberof AudioEffects
179
+ * @example
180
+ * const hall = new AudioReverb(4, 1.5, .4);
181
+ * footstep.output = hall;
182
+ */
183
+ class AudioReverb extends AudioEffect
184
+ {
185
+ /** Create a reverb effect
186
+ * @param {number} [duration] - Seconds until the reverb tail is silent
187
+ * @param {number} [decay] - How quickly the tail fades, higher is faster
188
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
189
+ constructor(duration=2, decay=2, mix=.5)
190
+ {
191
+ super(mix);
192
+
193
+ /** @property {ConvolverNode} - The convolver node */
194
+ this.node = audioContext.createConvolver();
195
+ this.setRoom(duration, decay);
196
+ this.connectEffect(this.node);
197
+ }
198
+
199
+ /** Change the room by rebuilding the impulse response
200
+ * @param {number} duration - Seconds until the reverb tail is silent
201
+ * @param {number} [decay] - How quickly the tail fades, higher is faster */
202
+ setRoom(duration, decay=2)
203
+ {
204
+ ASSERT(isNumber(duration) && duration > 0, 'duration must be positive');
205
+ ASSERT(isNumber(decay) && decay > 0, 'decay must be positive');
206
+ this.node.buffer = this.createImpulse(duration, decay);
207
+ }
208
+
209
+ /** Build a stereo impulse response of decaying noise
210
+ * @param {number} duration - Seconds until silence
211
+ * @param {number} decay - How quickly it fades, higher is faster
212
+ * @return {AudioBuffer} */
213
+ createImpulse(duration, decay)
214
+ {
215
+ const sampleRate = audioContext.sampleRate;
216
+ const length = max(1, sampleRate * duration | 0);
217
+ const buffer = audioContext.createBuffer(2, length, sampleRate);
218
+ for (let channel = 2; channel--;)
219
+ {
220
+ const samples = buffer.getChannelData(channel);
221
+ for (let i = length; i--;)
222
+ samples[i] = rand(-1, 1) * (1 - i/length) ** decay;
223
+ }
224
+ return buffer;
225
+ }
226
+ }
227
+
228
+ ///////////////////////////////////////////////////////////////////////////////
229
+ /**
230
+ * Delay effect, echoes that repeat and fade
231
+ * @extends AudioEffect
232
+ * @memberof AudioEffects
233
+ * @example
234
+ * const canyon = new AudioDelay(.4, .5);
235
+ * shout.output = canyon;
236
+ */
237
+ class AudioDelay extends AudioEffect
238
+ {
239
+ /** Create a delay effect
240
+ * @param {number} [time] - Seconds between echoes, up to 5
241
+ * @param {number} [feedback] - How much of each echo repeats, 0 to .95
242
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
243
+ constructor(time=.3, feedback=.4, mix=.5)
244
+ {
245
+ super(mix);
246
+
247
+ /** @property {DelayNode} - The delay node */
248
+ this.node = audioContext.createDelay(5);
249
+ /** @property {GainNode} - How much of the delayed signal feeds back in */
250
+ this.feedbackGain = audioContext.createGain();
251
+ this.node.connect(this.feedbackGain).connect(this.node);
252
+ this.connectEffect(this.node);
253
+ this.setTime(time);
254
+ this.setFeedback(feedback);
255
+ }
256
+
257
+ /** Set the time between echoes
258
+ * - Browsers hold a delay in a feedback loop to at least one render quantum, so 0 is not a bypass
259
+ * @param {number} time - Seconds, up to 5
260
+ * @param {number} [fadeTime] - Seconds to ramp over, pitch bends while it moves */
261
+ setTime(time, fadeTime=0)
262
+ {
263
+ ASSERT(isNumber(time) && time >= 0 && time <= 5, 'time must be between 0 and 5');
264
+ this.rampParam(this.node.delayTime, time, fadeTime);
265
+ }
266
+
267
+ /** Set how much of each echo repeats, clamped below 1 so it always dies out
268
+ * @param {number} feedback - 0 to .95
269
+ * @param {number} [fadeTime] - Seconds to ramp over */
270
+ setFeedback(feedback, fadeTime=0)
271
+ {
272
+ ASSERT(isNumber(feedback), 'feedback must be a number');
273
+ this.rampParam(this.feedbackGain.gain, clamp(feedback, 0, .95), fadeTime);
274
+ }
275
+ }
276
+
277
+ ///////////////////////////////////////////////////////////////////////////////
278
+ /**
279
+ * Distortion effect, overdrive for radios, damaged robots, and engines
280
+ * @extends AudioEffect
281
+ * @memberof AudioEffects
282
+ * @example
283
+ * const radio = new AudioDistortion(.8);
284
+ * voice.output = radio;
285
+ */
286
+ class AudioDistortion extends AudioEffect
287
+ {
288
+ /** Create a distortion effect
289
+ * @param {number} [amount] - How hard to drive the signal, 0 is clean and 1 is crushed
290
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
291
+ constructor(amount=.5, mix=1)
292
+ {
293
+ super(mix);
294
+
295
+ /** @property {WaveShaperNode} - The wave shaper node */
296
+ this.node = audioContext.createWaveShaper();
297
+ this.node.oversample = '2x';
298
+ /** @property {number} - How hard the signal is driven, 0 is clean and 1 is crushed */
299
+ this.amount = amount;
300
+ this.setAmount(amount);
301
+ this.connectEffect(this.node);
302
+ }
303
+
304
+ /** Set how hard to drive the signal, rebuilds the shaping curve
305
+ * @param {number} amount - 0 is clean and 1 is crushed */
306
+ setAmount(amount)
307
+ {
308
+ ASSERT(isNumber(amount), 'amount must be a number');
309
+ this.amount = amount = clamp(amount);
310
+
311
+ // soft clip curve, drive grows with the square of amount so low values stay subtle
312
+ // enough points that quiet signals are still shaped at high drive, where the curve is steep near 0
313
+ const drive = 100 * amount * amount;
314
+ const samples = 1024;
315
+ const curve = new Float32Array(samples);
316
+ for (let i = samples; i--;)
317
+ {
318
+ const x = i * 2 / (samples - 1) - 1;
319
+ curve[i] = (1 + drive) * x / (1 + drive * abs(x));
320
+ }
321
+ this.node.curve = curve;
322
+ }
323
+ }
324
+
325
+ ///////////////////////////////////////////////////////////////////////////////
326
+ /**
327
+ * Compressor effect, evens out loud and quiet so many sounds at once don't clip
328
+ * - Meant for the master bus, it is not on by default
329
+ * @extends AudioEffect
330
+ * @memberof AudioEffects
331
+ * @example
332
+ * const compressor = new AudioCompressor;
333
+ * setAudioMasterEffect(compressor);
334
+ */
335
+ class AudioCompressor extends AudioEffect
336
+ {
337
+ /** Create a compressor effect
338
+ * @param {number} [threshold] - Level in dB above which the signal is reduced
339
+ * @param {number} [ratio] - How much to reduce it, 12 means 12 dB in becomes 1 dB out
340
+ * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
341
+ constructor(threshold=-24, ratio=12, mix=1)
342
+ {
343
+ super(mix);
344
+ ASSERT(isNumber(threshold), 'threshold must be a number');
345
+ ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more');
346
+
347
+ /** @property {DynamicsCompressorNode} - The compressor node */
348
+ this.node = audioContext.createDynamicsCompressor();
349
+ this.node.threshold.value = threshold;
350
+ this.node.ratio.value = ratio;
351
+ this.connectEffect(this.node);
352
+ }
353
+
354
+ /** Set the level above which the signal is reduced
355
+ * @param {number} threshold - Level in dB
356
+ * @param {number} [fadeTime] - Seconds to ramp over */
357
+ setThreshold(threshold, fadeTime=0)
358
+ {
359
+ ASSERT(isNumber(threshold), 'threshold must be a number');
360
+ this.rampParam(this.node.threshold, threshold, fadeTime);
361
+ }
362
+
363
+ /** Set how much the signal is reduced above the threshold
364
+ * @param {number} ratio - 1 is no reduction, 20 is a hard limit
365
+ * @param {number} [fadeTime] - Seconds to ramp over */
366
+ setRatio(ratio, fadeTime=0)
367
+ {
368
+ ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more');
369
+ this.rampParam(this.node.ratio, ratio, fadeTime);
370
+ }
371
+ }
@@ -37,7 +37,7 @@ let lightSystem;
37
37
  class LightSystemPlugin
38
38
  {
39
39
  /** Create the global light system plugin.
40
- * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
40
+ * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize, which is css pixels, so the lightmap is not scaled by canvasPixelRatio; pass mainCanvasSize.scale(getCanvasPixelRatio()) for a full resolution lightmap)
41
41
  * @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
42
42
  * @example
43
43
  * // simplest usage
@@ -53,7 +53,7 @@ class LightSystemPlugin
53
53
  this.enabled = true;
54
54
  /** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
55
55
  this.ambientColor = (ambientColor || BLACK).copy();
56
- /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
56
+ /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize in css pixels at init time, so it is not scaled by canvasPixelRatio) */
57
57
  this.textureSize = textureSize ? textureSize.copy() : undefined;
58
58
 
59
59
  /** @property {WebGLTexture} - The lightmap texture */
@@ -195,7 +195,9 @@ class LightSystemPlugin
195
195
  // canvas after we unbind
196
196
  glFlush();
197
197
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
198
- glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
198
+
199
+ // backing store size, mainCanvasSize is css pixels
200
+ glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
199
201
 
200
202
  // 5. composite: fullscreen quad, multiplicative blend onto glCanvas
201
203
  // (scene * lightmap — unlit areas go to black, lit areas are