littlejsengine 1.18.29 → 1.20.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.
Files changed (51) hide show
  1. package/FAQ.md +841 -815
  2. package/README.md +211 -199
  3. package/dist/littlejs.d.ts +4383 -812
  4. package/dist/littlejs.esm.js +31848 -16680
  5. package/dist/littlejs.esm.min.js +14 -1
  6. package/dist/littlejs.js +31230 -16166
  7. package/dist/littlejs.min.js +13 -1
  8. package/dist/littlejs.release.js +27419 -15457
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +375 -0
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -330
  16. package/plugins/math3d.js +911 -0
  17. package/plugins/medalSystem.js +118 -72
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +196 -115
  21. package/plugins/postProcess.js +284 -166
  22. package/plugins/render3d.js +4072 -0
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +86 -48
  27. package/plugins/threejs.js +183 -158
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +646 -526
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +8 -15
  32. package/src/engine.js +345 -194
  33. package/src/engineAudio.js +1022 -777
  34. package/src/engineBuild.mjs +289 -253
  35. package/src/engineDebug.js +912 -735
  36. package/src/engineDraw.js +1774 -1510
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +419 -396
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineLogo.js +4 -3
  41. package/src/engineMath.js +1274 -1183
  42. package/src/engineObject.js +674 -551
  43. package/src/engineParticles.js +583 -532
  44. package/src/engineRelease.js +67 -45
  45. package/src/engineSettings.js +808 -726
  46. package/src/engineTileLayer.js +995 -741
  47. package/src/engineUtilities.js +32 -13
  48. package/src/engineWebGL.js +1162 -941
  49. package/src/jsconfig.json +17 -13
  50. package/plugins/box2d.wasm.js +0 -630
  51. package/plugins/box2d.wasm.wasm +0 -0
package/src/engineDraw.js CHANGED
@@ -1,1511 +1,1775 @@
1
- /**
2
- * LittleJS Drawing System
3
- * - Hybrid rendering with both Canvas2D and WebGL support
4
- * - Optimized tile sheet sprite rendering using WebGL batching
5
- * - Primitive drawing for polygons, ellipses, and lines
6
- * - Tile-based rendering with TileInfo and TextureInfo classes
7
- * - Text rendering with custom fonts and ImageFont support
8
- * - Color and additive color blending for effects
9
- * - Rotation, mirroring, and scaling transformations
10
- * - Camera system with position, scale, and rotation
11
- * - Multiple canvas support (main, WebGL, work canvases)
12
- * - Gradient fills and outlined shapes
13
- * - Image manipulation and color tinting
14
- *
15
- * Rendering Architecture:
16
- * - glCanvas: WebGL canvas for accelerated sprite batch rendering
17
- * - mainCanvas: Canvas2D overlay for text, UI, and custom drawing
18
- * - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter
19
- *
20
- * @namespace Draw
21
- */
22
-
23
- 'use strict';
24
-
25
- /** The primary 2D canvas visible to the user
26
- * @type {HTMLCanvasElement}
27
- * @memberof Draw */
28
- let mainCanvas;
29
-
30
- /** 2d context for mainCanvas
31
- * @type {CanvasRenderingContext2D}
32
- * @memberof Draw */
33
- let mainContext;
34
-
35
- /** The default 2d context to use for drawing, usually mainContext
36
- * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
37
- * @memberof Draw */
38
- let drawContext;
39
-
40
- /** Offscreen canvas that can be used for image processing
41
- * @type {OffscreenCanvas}
42
- * @memberof Draw */
43
- let workCanvas;
44
-
45
- /** Offscreen canvas that can be used for image processing
46
- * @type {OffscreenCanvasRenderingContext2D}
47
- * @memberof Draw */
48
- let workContext;
49
-
50
- /** Offscreen canvas with willReadFrequently that can be used for image processing
51
- * @type {OffscreenCanvas}
52
- * @memberof Draw */
53
- let workReadCanvas;
54
-
55
- /** Offscreen canvas with willReadFrequently that can be used for image processing
56
- * @type {OffscreenCanvasRenderingContext2D}
57
- * @memberof Draw */
58
- let workReadContext;
59
-
60
- /** Extra canvas to composite behind the engine canvases when combining canvases
61
- * Set by plugins that render to their own canvas below the LittleJS canvases
62
- * @type {HTMLCanvasElement}
63
- * @memberof Draw */
64
- let backgroundCanvas;
65
-
66
- /** The size of the main canvas (and other secondary canvases)
67
- * @type {Vector2}
68
- * @memberof Draw */
69
- let mainCanvasSize = vec2();
70
-
71
- /** Array containing texture info for batch rendering system
72
- * @type {Array<TextureInfo>}
73
- * @memberof Draw */
74
- let textureInfos = [];
75
-
76
- /** Keeps track of how many draw calls there were each frame for debugging
77
- * @type {number}
78
- * @memberof Draw */
79
- let drawCount;
80
-
81
- /** Keeps track of how many primitives were drawn each frame for debugging
82
- * A single draw call can render many primitives (e.g. a WebGL sprite batch).
83
- * @type {number}
84
- * @memberof Draw */
85
- let primitiveCount;
86
-
87
- // internal predicates for tint short-circuiting in canvas2D draw paths
88
- // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
89
- // isBlack includes alpha so additive colors that only contribute alpha are not skipped
90
- /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
91
- /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
92
-
93
- ///////////////////////////////////////////////////////////////////////////////
94
-
95
- /**
96
- * Create a tile info object using a grid based system
97
- * - This can take vecs or floats for easier use and conversion
98
- * - If an index is passed in, the tile size and index will determine the position
99
- * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
100
- * @param {Vector2|number} [size] - Size of tile in pixels
101
- * @param {TextureInfo|number} [texture] - Texture index or info to use
102
- * @param {number} [padding] - How many pixels padding around tiles
103
- * @param {number} [bleed] - How many pixels smaller to draw tiles
104
- * @return {TileInfo}
105
- * @example
106
- * tile(2) // a tile at index 2 using the default tile size of 16
107
- * tile(5, 8) // a tile at index 5 using a tile size of 8
108
- * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
109
- * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
110
- * @memberof Draw */
111
- function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
112
- {
113
- ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
114
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
115
- ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
116
- ASSERT(isNumber(padding), 'padding must be a number');
117
-
118
- if (headlessMode) return new TileInfo;
119
-
120
- if (typeof size === 'number')
121
- {
122
- // if size is a number, make it a vector
123
- ASSERT(size > 0);
124
- size = new Vector2(size, size);
125
- }
126
-
127
- // create tile info object
128
- const textureInfo = typeof texture === 'number' ?
129
- textureInfos[texture] : texture;
130
- ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
131
- ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
132
-
133
- // get the position of the tile
134
- const sizePaddedX = size.x + padding*2;
135
- const sizePaddedY = size.y + padding*2;
136
- let x, y;
137
- if (typeof index === 'number')
138
- {
139
- const cols = textureInfo.size.x / sizePaddedX |0;
140
- x = index % cols;
141
- y = index / cols |0;
142
- }
143
- else
144
- {
145
- x = index.x;
146
- y = index.y;
147
- }
148
- const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding);
149
- return new TileInfo(pos, size, textureInfo, padding, bleed);
150
- }
151
-
152
- /**
153
- * Tile Info - Stores info about how to draw a tile
154
- * @memberof Draw
155
- */
156
- class TileInfo
157
- {
158
- /** Create a tile info object
159
- * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
160
- * @param {Vector2} [size] - Size of tile in pixels
161
- * @param {TextureInfo} [textureInfo] - Texture info to use
162
- * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
163
- * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
164
- * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
165
- */
166
- constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
167
- {
168
- /** @property {Vector2} - Top left corner of tile in pixels */
169
- this.pos = pos.copy();
170
- /** @property {Vector2} - Size of tile in pixels */
171
- this.size = size.copy();
172
- /** @property {number} - How many pixels padding around tiles */
173
- this.padding = padding;
174
- /** @property {TextureInfo} - The texture info for this tile */
175
- this.textureInfo = textureInfo;
176
- /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
177
- this.bleed = bleed;
178
- /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
179
- this.columns = columns;
180
- }
181
-
182
- /** Returns a copy of this tile offset by a vector
183
- * @param {Vector2} offset - Offset to apply in pixels
184
- * @return {TileInfo}
185
- */
186
- offset(offset)
187
- { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
188
-
189
- /** Returns a copy of this tile offset by a number of animation frames
190
- * Frames wrap down to the next row if columns is set
191
- * @param {number} frame - Offset to apply in animation frames
192
- * @return {TileInfo}
193
- */
194
- frame(frame)
195
- {
196
- ASSERT(typeof frame === 'number');
197
- const w = this.size.x + this.padding*2;
198
- const h = this.size.y + this.padding*2;
199
- const x = (this.columns ? frame % this.columns : frame) * w;
200
- const y = (this.columns ? frame / this.columns | 0 : 0) * h;
201
- ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
202
- ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
203
- return this.offset(new Vector2(x, y));
204
- }
205
-
206
- /** Set how many frames per row this tile uses, so frame() can wrap
207
- * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
208
- * @return {TileInfo}
209
- */
210
- setColumns(columns=0)
211
- {
212
- ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
213
- this.columns = columns;
214
- return this;
215
- }
216
-
217
- /**
218
- * Returns a tile info for an index using this tile as reference
219
- * @param {Vector2|number} [index=0]
220
- * @return {TileInfo}
221
- */
222
- index(index)
223
- { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
224
-
225
- /**
226
- * Set this tile to use a full image in a texture info
227
- * @param {TextureInfo} [textureInfo]
228
- * @return {TileInfo}
229
- */
230
- setFullImage(textureInfo=this.textureInfo)
231
- {
232
- this.textureInfo = textureInfo;
233
- this.pos = new Vector2;
234
- this.size = textureInfo.size.copy();
235
- this.bleed = this.padding = this.columns = 0;
236
- return this;
237
- }
238
- }
239
-
240
- /**
241
- * Tile Info - Stores info about each texture
242
- * @memberof Draw
243
- */
244
- class TextureInfo
245
- {
246
- /**
247
- * Create a TextureInfo, called automatically by the engine
248
- * @param {HTMLImageElement|OffscreenCanvas} image
249
- * @param {boolean} [useWebGL] - Should use WebGL if available?
250
- * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
251
- */
252
- constructor(image, useWebGL=true, wrap=false)
253
- {
254
- /** @property {HTMLImageElement|OffscreenCanvas} - image source */
255
- this.image = image;
256
- /** @property {Vector2} - size of the image */
257
- this.size = image ? vec2(image.width, image.height) : vec2();
258
- /** @property {Vector2} - inverse of the size, cached for rendering */
259
- this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
260
- /** @property {WebGLTexture} - WebGL texture */
261
- this.glTexture = undefined;
262
- /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
263
- this.wrap = wrap;
264
- useWebGL && this.createWebGLTexture();
265
- }
266
-
267
- /** Creates the WebGL texture, updates if already created */
268
- createWebGLTexture() { glRegisterTextureInfo(this); }
269
-
270
- /** Destroys the WebGL texture */
271
- destroyWebGLTexture() { glUnregisterTextureInfo(this); }
272
-
273
- /** Check if the texture is webgl enabled
274
- * @return {boolean} */
275
- hasWebGL() { return !!this.glTexture; }
276
-
277
- /** Set the wrap mode for this texture
278
- * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
279
- setWrap(wrap=true)
280
- {
281
- this.wrap = wrap;
282
- glSetTextureWrap(this.glTexture, wrap);
283
- }
284
- }
285
-
286
- ///////////////////////////////////////////////////////////////////////////////
287
- // Drawing functions
288
-
289
- /** Draw textured tile centered in world space
290
- * @param {Vector2} pos - Center of the tile in world space
291
- * @param {Vector2} [size=vec2(1)] - Size of the tile in world space
292
- * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
293
- * @param {Color} [color=WHITE] - Color to modulate with
294
- * @param {number} [angle] - Angle to rotate by
295
- * @param {boolean} [mirror] - Is image flipped along the Y axis?
296
- * @param {Color} [additiveColor] - Additive color to be applied if any
297
- * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
298
- * @param {boolean} [screenSpace=false] - Are the pos and size are in screen space?
299
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
300
- * @memberof Draw */
301
- function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
302
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
303
- {
304
- ASSERT(isVector2(pos), 'pos must be a vec2');
305
- ASSERT(isVector2(size), 'size must be a vec2');
306
- ASSERT(isColor(color), 'color is invalid');
307
- ASSERT(isNumber(angle), 'angle must be a number');
308
- ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
309
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
310
-
311
- const textureInfo = tileInfo?.textureInfo;
312
- const bleed = tileInfo?.bleed ?? 0;
313
- if (useWebGL && glEnable)
314
- {
315
- ASSERT(!!glContext, 'WebGL is not enabled!');
316
- if (screenSpace)
317
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
318
- if (textureInfo)
319
- {
320
- // calculate uvs and render
321
- const sizeInverse = textureInfo.sizeInverse;
322
- const x = tileInfo.pos.x * sizeInverse.x;
323
- const y = tileInfo.pos.y * sizeInverse.y;
324
- const w = tileInfo.size.x * sizeInverse.x;
325
- const h = tileInfo.size.y * sizeInverse.y;
326
- glSetTexture(textureInfo.glTexture);
327
- if (bleed)
328
- {
329
- const bleedX = sizeInverse.x*bleed;
330
- const bleedY = sizeInverse.y*bleed;
331
- glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
332
- x + bleedX, y + bleedY,
333
- x - bleedX + w, y - bleedY + h,
334
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
335
- }
336
- else
337
- {
338
- glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
339
- x, y, x + w, y + h,
340
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
341
- }
342
- }
343
- else
344
- {
345
- // untextured: fold color+additive to match the Canvas2D path's
346
- // color.add(additiveColor) on line ~337.
347
- const combined = additiveColor ? color.add(additiveColor) : color;
348
- glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
349
- }
350
- }
351
- else
352
- {
353
- // normal canvas 2D rendering method (slower)
354
- ++drawCount;
355
- ++primitiveCount;
356
- drawCanvas2D(pos, size, angle, mirror, (context)=>
357
- {
358
- if (textureInfo)
359
- {
360
- // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
361
- context.scale(1, -1);
362
- // calculate uvs and render
363
- const x = tileInfo.pos.x, y = tileInfo.pos.y;
364
- const w = tileInfo.size.x, h = tileInfo.size.y;
365
- drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed);
366
- }
367
- else
368
- {
369
- // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
370
- const c = additiveColor ? color.add(additiveColor) : color;
371
- context.fillStyle = c.toString();
372
- context.fillRect(-.5, -.5, 1, 1);
373
- }
374
- }, screenSpace, context);
375
- }
376
- }
377
-
378
- /** Draw colored rect centered on pos
379
- * @param {Vector2} pos
380
- * @param {Vector2} [size=vec2(1)]
381
- * @param {Color} [color=WHITE]
382
- * @param {number} [angle]
383
- * @param {boolean} [useWebGL=glEnable]
384
- * @param {boolean} [screenSpace]
385
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
386
- * @memberof Draw */
387
- function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
388
- {
389
- drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context);
390
- }
391
-
392
- /** Draw a rect centered on pos with a gradient from top to bottom
393
- * @param {Vector2} pos
394
- * @param {Vector2} [size=vec2(1)]
395
- * @param {Color} [colorTop=WHITE]
396
- * @param {Color} [colorBottom=CLEAR_WHITE]
397
- * @param {number} [angle]
398
- * @param {boolean} [useWebGL=glEnable]
399
- * @param {boolean} [screenSpace]
400
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
401
- * @memberof Draw */
402
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
403
- {
404
- ASSERT(isVector2(pos), 'pos must be a vec2');
405
- ASSERT(isVector2(size), 'size must be a vec2');
406
- ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
407
- ASSERT(isNumber(angle), 'angle must be a number');
408
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
409
-
410
- if (useWebGL && glEnable)
411
- {
412
- ASSERT(!!glContext, 'WebGL is not enabled!');
413
- if (screenSpace)
414
- {
415
- // convert to world space
416
- pos = screenToWorld(pos);
417
- size = size.scale(1/cameraScale);
418
- angle += cameraAngle;
419
- }
420
- // build 4 corner points for the rectangle
421
- const points = [], colors = [];
422
- const halfSizeX = size.x/2, halfSizeY = size.y/2;
423
- const colorTopInt = colorTop.rgbaInt();
424
- const colorBottomInt = colorBottom.rgbaInt();
425
- const c = cos(-angle), s = sin(-angle);
426
- for (let i=4; i--;)
427
- {
428
- const x = i & 1 ? halfSizeX : -halfSizeX;
429
- const y = i & 2 ? halfSizeY : -halfSizeY;
430
- const rx = x * c - y * s;
431
- const ry = x * s + y * c;
432
- const color = i & 2 ? colorTopInt : colorBottomInt;
433
- points.push(vec2(pos.x + rx, pos.y + ry));
434
- colors.push(color);
435
- }
436
- glDrawColoredPoints(points, colors);
437
- }
438
- else
439
- {
440
- // normal canvas 2D rendering method (slower)
441
- ++drawCount;
442
- ++primitiveCount;
443
- drawCanvas2D(pos, size, angle, false, (context)=>
444
- {
445
- // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
446
- const gradient = context.createLinearGradient(0, .5, 0, -.5);
447
- gradient.addColorStop(0, colorTop.toString());
448
- gradient.addColorStop(1, colorBottom.toString());
449
- context.fillStyle = gradient;
450
- context.fillRect(-.5, -.5, 1, 1);
451
- }, screenSpace, context);
452
- }
453
- }
454
-
455
- /** Draw a texture tiled (wrapped) across a rectangle in world space.
456
- * Useful for backgrounds, repeating patterns, and seamless fills.
457
- * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
458
- * @param {Vector2} pos - Center of the rect in world space
459
- * @param {Vector2} size - Size of the rect in world space
460
- * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
461
- * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
462
- * @param {Color} [color=WHITE] - Color to modulate with
463
- * @param {number} [angle=0] - Angle to rotate by
464
- * @param {Color} [additiveColor] - Additive color to be applied if any
465
- * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
466
- * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
467
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
468
- * @memberof Draw */
469
- function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
470
- angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
471
- {
472
- ASSERT(isVector2(pos), 'pos must be a vec2');
473
- ASSERT(isVector2(size), 'size must be a vec2');
474
- ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
475
- ASSERT(isColor(color), 'color is invalid');
476
- ASSERT(isNumber(angle), 'angle must be a number');
477
- ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
478
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
479
- ASSERT(!(texture instanceof TileInfo),
480
- 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
481
-
482
- // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
483
- if (headlessMode) return;
484
-
485
- // resolve texture argument: TextureInfo or index
486
- const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
487
- ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
488
- ASSERT(textureInfo.size.x > 0, 'texture not loaded');
489
- ASSERT(textureInfo.wrap,
490
- 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
491
-
492
- if (useWebGL && glEnable)
493
- {
494
- ASSERT(!!glContext, 'WebGL is not enabled!');
495
- if (screenSpace)
496
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
497
- glSetTexture(textureInfo.glTexture);
498
- glDraw(pos.x, pos.y, size.x, size.y, angle,
499
- 0, 0, wrapCount.x, wrapCount.y,
500
- color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
501
- return;
502
- }
503
-
504
- // Canvas2D path — increment counts here (WebGL counts via glFlush)
505
- ++drawCount;
506
- ++primitiveCount;
507
-
508
- if (!screenSpace)
509
- {
510
- pos = worldToScreen(pos);
511
- size = size.scale(cameraScale);
512
- angle -= cameraAngle;
513
- }
514
-
515
- // pick image source: raw, or tinted bake. Match drawImageColor's
516
- // "no tint needed" predicate so behavior stays consistent.
517
- const noTint = !canvasColorTiles ||
518
- (additiveColor
519
- ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
520
- : isWhite(color));
521
- // alpha is baked into pixels by bakeTintedImage's additive branch;
522
- // in that case globalAlpha must NOT also apply color.a
523
- const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
524
- const source = noTint
525
- ? textureInfo.image
526
- : bakeTintedImage(textureInfo.image, color, additiveColor);
527
-
528
- context = context || drawContext;
529
- context.save();
530
- context.translate(pos.x + .5, pos.y + .5);
531
- context.rotate(angle);
532
- context.globalAlpha = alphaBaked ? 1 : color.a;
533
-
534
- const pattern = context.createPattern(source, 'repeat');
535
- // map pattern-source pixels into user space so the rect contains
536
- // wrapCount.x × wrapCount.y repeats
537
- const m = new DOMMatrix()
538
- .translate(-size.x/2, -size.y/2)
539
- .scale(size.x / (wrapCount.x * source.width),
540
- size.y / (wrapCount.y * source.height));
541
- pattern.setTransform(m);
542
- context.fillStyle = pattern;
543
- context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
544
- context.globalAlpha = 1;
545
- context.restore();
546
- }
547
-
548
- /** Draw connected lines between a series of points
549
- * @param {Array<Vector2>} points
550
- * @param {number} [width]
551
- * @param {Color} [color=WHITE]
552
- * @param {boolean} [wrap] - Should the last point connect to the first?
553
- * @param {Vector2} [pos=vec2()] - Offset to apply
554
- * @param {number} [angle] - Angle to rotate by
555
- * @param {boolean} [useWebGL=glEnable]
556
- * @param {boolean} [screenSpace]
557
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
558
- * @memberof Draw */
559
- function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
560
- {
561
- ASSERT(isArray(points), 'points must be an array');
562
- ASSERT(isNumber(width), 'width must be a number');
563
- ASSERT(isColor(color), 'color is invalid');
564
- ASSERT(isVector2(pos), 'pos must be a vec2');
565
- ASSERT(isNumber(angle), 'angle must be a number');
566
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
567
-
568
- if (useWebGL && glEnable)
569
- {
570
- ASSERT(!!glContext, 'WebGL is not enabled!');
571
- let size = vec2(1);
572
- if (screenSpace)
573
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
574
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
575
- }
576
- else
577
- {
578
- // normal canvas 2D rendering method (slower)
579
- ++drawCount;
580
- ++primitiveCount;
581
- drawCanvas2D(pos, vec2(1), angle, false, (context)=>
582
- {
583
- context.strokeStyle = color.toString();
584
- context.lineWidth = width;
585
- context.beginPath();
586
- for (let i=0; i<points.length; ++i)
587
- {
588
- const point = points[i];
589
- context.lineTo(point.x, point.y);
590
- }
591
- wrap && context.closePath();
592
- context.stroke();
593
- }, screenSpace, context);
594
- }
595
- }
596
-
597
- /** Draw colored line between two points
598
- * @param {Vector2} posA
599
- * @param {Vector2} posB
600
- * @param {number} [width]
601
- * @param {Color} [color=WHITE]
602
- * @param {Vector2} [pos=vec2()] - Offset to apply
603
- * @param {number} [angle] - Angle to rotate by
604
- * @param {boolean} [useWebGL=glEnable]
605
- * @param {boolean} [screenSpace]
606
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
607
- * @memberof Draw */
608
- function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
609
- {
610
- const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
611
- const size = vec2(width, halfDelta.length()*2);
612
- pos = pos.add(posA.add(halfDelta));
613
- if (screenSpace)
614
- halfDelta.y *= -1; // flip angle Y if screen space
615
- angle += halfDelta.angle();
616
- drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
617
- }
618
-
619
- /** Draw colored regular polygon using passed in number of sides
620
- * @param {Vector2} pos
621
- * @param {Vector2} [size=vec2(1)]
622
- * @param {number} [sides]
623
- * @param {Color} [color=WHITE]
624
- * @param {number} [lineWidth]
625
- * @param {Color} [lineColor=BLACK]
626
- * @param {number} [angle]
627
- * @param {boolean} [useWebGL=glEnable]
628
- * @param {boolean} [screenSpace]
629
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
630
- * @memberof Draw */
631
- function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
632
- {
633
- ASSERT(isVector2(size), 'size must be a vec2');
634
- ASSERT(isNumber(sides), 'sides must be a number');
635
-
636
- // build regular polygon points
637
- const points = [];
638
- const sizeX = size.x/2, sizeY = size.y/2;
639
- for (let i=sides; i--;)
640
- {
641
- const a = (i/sides)*PI*2;
642
- points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
643
- }
644
- drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
645
- }
646
-
647
- /** Draw colored polygon using passed in points
648
- * @param {Array<Vector2>} points - Array of Vector2 points
649
- * @param {Color} [color=WHITE]
650
- * @param {number} [lineWidth]
651
- * @param {Color} [lineColor=BLACK]
652
- * @param {Vector2} [pos=vec2()] - Offset to apply
653
- * @param {number} [angle] - Angle to rotate by
654
- * @param {boolean} [useWebGL=glEnable]
655
- * @param {boolean} [screenSpace]
656
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
657
- * @memberof Draw */
658
- function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context=undefined)
659
- {
660
- ASSERT(isVector2(pos), 'pos must be a vec2');
661
- ASSERT(isArray(points), 'points must be an array');
662
- ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
663
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
664
- ASSERT(isNumber(angle), 'angle must be a number');
665
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
666
-
667
- if (useWebGL && glEnable)
668
- {
669
- ASSERT(!!glContext, 'WebGL is not enabled!');
670
- let size = vec2(1);
671
- if (screenSpace)
672
- [pos, size, angle] = screenToWorldTransform(pos, size, angle);
673
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
674
- if (lineWidth > 0)
675
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
676
- }
677
- else
678
- {
679
- drawCanvas2D(pos, vec2(1), angle, false, context=>
680
- {
681
- context.fillStyle = color.toString();
682
- context.beginPath();
683
- for (const point of points)
684
- context.lineTo(point.x, point.y);
685
- context.closePath();
686
- context.fill();
687
- if (lineWidth)
688
- {
689
- context.strokeStyle = lineColor.toString();
690
- context.lineWidth = lineWidth;
691
- context.stroke();
692
- }
693
- }, screenSpace, context);
694
- }
695
- }
696
-
697
- /** Draw colored ellipse using passed in point
698
- * @param {Vector2} pos
699
- * @param {Vector2} [size=vec2(1)] - Width and height diameter
700
- * @param {Color} [color=WHITE]
701
- * @param {number} [angle]
702
- * @param {number} [lineWidth]
703
- * @param {Color} [lineColor=BLACK]
704
- * @param {boolean} [useWebGL=glEnable]
705
- * @param {boolean} [screenSpace]
706
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
707
- * @memberof Draw */
708
- function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
709
- {
710
- ASSERT(isVector2(pos), 'pos must be a vec2');
711
- ASSERT(isVector2(size), 'size must be a vec2');
712
- ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
713
- ASSERT(isNumber(angle), 'angle must be a number');
714
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
715
- ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
716
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
717
-
718
- // clamp line width to prevent artifacts
719
- lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
720
-
721
- if (useWebGL && glEnable)
722
- {
723
- // draw as a regular polygon
724
- const sides = glCircleSides;
725
- drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
726
- }
727
- else
728
- {
729
- drawCanvas2D(pos, vec2(1), angle, false, context=>
730
- {
731
- context.fillStyle = color.toString();
732
- context.beginPath();
733
- context.ellipse(0, 0, size.x/2, size.y/2, 0, 0, 9);
734
- context.fill();
735
- if (lineWidth)
736
- {
737
- context.strokeStyle = lineColor.toString();
738
- context.lineWidth = lineWidth;
739
- context.stroke();
740
- }
741
- }, screenSpace, context);
742
- }
743
- }
744
-
745
- /** Draw colored circle using passed in point
746
- * @param {Vector2} pos
747
- * @param {number} [size=1] - Diameter
748
- * @param {Color} [color=WHITE]
749
- * @param {number} [lineWidth=0]
750
- * @param {Color} [lineColor=BLACK]
751
- * @param {boolean} [useWebGL=glEnable]
752
- * @param {boolean} [screenSpace]
753
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
754
- * @memberof Draw */
755
- function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
756
- {
757
- ASSERT(isNumber(size), 'size must be a number');
758
- drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
759
- }
760
-
761
- /** Draw an ellipse filled with a radial gradient from the center to the rim
762
- * - Best when batched with other untextured polys
763
- * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
764
- * - Stacking gradients at the exact same position may show a faint vertical artifact
765
- * @param {Vector2} pos
766
- * @param {Vector2} [size=vec2(1)] - Width and height diameter
767
- * @param {Color} [colorInner=WHITE]
768
- * @param {Color} [colorOuter=CLEAR_WHITE]
769
- * @param {number} [angle]
770
- * @param {boolean} [useWebGL=glEnable]
771
- * @param {boolean} [screenSpace]
772
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
773
- * @memberof Draw */
774
- let drawEllipseGradientOffset = 0;
775
- function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
776
- {
777
- ASSERT(isVector2(pos), 'pos must be a vec2');
778
- ASSERT(isVector2(size), 'size must be a vec2');
779
- ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
780
- ASSERT(isNumber(angle), 'angle must be a number');
781
- ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
782
-
783
- if (headlessMode) return;
784
-
785
- if (useWebGL && glEnable)
786
- {
787
- ASSERT(!!glContext, 'WebGL is not enabled!');
788
- if (screenSpace)
789
- {
790
- // convert to world space
791
- pos = screenToWorld(pos);
792
- size = size.scale(1/cameraScale);
793
- angle += cameraAngle;
794
- }
795
- // fan as tristrip; rotate the boundary vertex by one slice per call
796
- // so back-to-back gradients at the same position have their hole
797
- // (from gpu edge-rule on the boundary line-degen) at different rim
798
- // verts and don't visibly stack
799
- const sides = glCircleSides;
800
- const radiusX = size.x/2, radiusY = size.y/2;
801
- const innerInt = colorInner.rgbaInt();
802
- const outerInt = colorOuter.rgbaInt();
803
- const offset = drawEllipseGradientOffset++;
804
- const c = cos(-angle), s = sin(-angle);
805
- const rim = (a) =>
806
- {
807
- const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
808
- return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
809
- };
810
- const startA = (offset%sides)/sides*PI*2;
811
- const points = [rim(startA)];
812
- const colors = [outerInt];
813
- for (let i=sides; i--;)
814
- {
815
- const a = ((i+offset)%sides)/sides*PI*2;
816
- points.push(pos);
817
- colors.push(innerInt);
818
- points.push(rim(a));
819
- colors.push(outerInt);
820
- }
821
- glDrawColoredPoints(points, colors);
822
- }
823
- else
824
- {
825
- // normal canvas 2D rendering method (slower)
826
- ++drawCount;
827
- ++primitiveCount;
828
- drawCanvas2D(pos, size, angle, false, (context)=>
829
- {
830
- const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
831
- gradient.addColorStop(0, colorInner.toString());
832
- gradient.addColorStop(1, colorOuter.toString());
833
- context.fillStyle = gradient;
834
- context.beginPath();
835
- context.ellipse(0, 0, .5, .5, 0, 0, 9);
836
- context.fill();
837
- }, screenSpace, context);
838
- }
839
- }
840
-
841
- /** Draw a circle filled with a radial gradient from the center to the rim
842
- * - Best when batched with other untextured polys
843
- * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
844
- * - Stacking gradients at the exact same position may show a faint vertical artifact
845
- * @param {Vector2} pos
846
- * @param {number} [size=1] - Diameter
847
- * @param {Color} [colorInner=WHITE]
848
- * @param {Color} [colorOuter=CLEAR_WHITE]
849
- * @param {boolean} [useWebGL=glEnable]
850
- * @param {boolean} [screenSpace]
851
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
852
- * @memberof Draw */
853
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
854
- {
855
- ASSERT(isNumber(size), 'size must be a number');
856
- drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
857
- }
858
-
859
- /**
860
- * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
861
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
862
- * @memberof Draw
863
- */
864
-
865
- /** Draw directly to a 2d canvas context in world space.
866
- * The Y axis is flipped so world-Y-up coordinates render right-side up
867
- * (matches the WebGL path). Callers whose drawing depends on Y direction
868
- * (e.g. linear gradients) should flip their own Y endpoints accordingly.
869
- * @param {Vector2} pos
870
- * @param {Vector2} size
871
- * @param {number} angle
872
- * @param {boolean} [mirror]
873
- * @param {Canvas2DDrawFunction} [drawFunction]
874
- * @param {boolean} [screenSpace=false]
875
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
876
- * @memberof Draw */
877
- function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=false, context=drawContext)
878
- {
879
- ASSERT(isVector2(pos), 'pos must be a vec2');
880
- ASSERT(isVector2(size), 'size must be a vec2');
881
- ASSERT(isNumber(angle), 'angle must be a number');
882
- ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
883
-
884
- if (!screenSpace)
885
- {
886
- pos = worldToScreen(pos);
887
- size = size.scale(cameraScale);
888
- angle -= cameraAngle;
889
- }
890
- context.save();
891
- context.translate(pos.x+.5, pos.y+.5);
892
- context.rotate(angle);
893
- context.scale(mirror ? -size.x : size.x, -size.y);
894
- drawFunction(context);
895
- context.restore();
896
- }
897
-
898
- ///////////////////////////////////////////////////////////////////////////////
899
- // Text Drawing Functions
900
-
901
- /** Draw text on main canvas in world space
902
- * Automatically splits new lines into rows
903
- * @param {string|number} text
904
- * @param {Vector2} pos
905
- * @param {number} [size]
906
- * @param {Color} [color=WHITE]
907
- * @param {number} [lineWidth]
908
- * @param {Color} [lineColor=BLACK]
909
- * @param {CanvasTextAlign} [textAlign='center']
910
- * @param {string} [font=fontDefault]
911
- * @param {string} [fontStyle]
912
- * @param {number} [maxWidth]
913
- * @param {number} [angle]
914
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
915
- * @memberof Draw */
916
- function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
917
- {
918
- // convert to screen space
919
- pos = worldToScreen(pos);
920
- size *= cameraScale;
921
- lineWidth *= cameraScale;
922
- angle -= cameraAngle;
923
- angle *= -1;
924
-
925
- drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
926
- }
927
-
928
- /** Draw text in screen space
929
- * Automatically splits new lines into rows
930
- * @param {string|number} text
931
- * @param {Vector2} pos
932
- * @param {number} size
933
- * @param {Color} [color=WHITE]
934
- * @param {number} [lineWidth]
935
- * @param {Color} [lineColor=BLACK]
936
- * @param {CanvasTextAlign} [textAlign]
937
- * @param {string} [font=fontDefault]
938
- * @param {string} [fontStyle]
939
- * @param {number} [maxWidth]
940
- * @param {number} [angle]
941
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
942
- * @memberof Draw */
943
- function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
944
- {
945
- ASSERT(isStringLike(text), 'text must be a string');
946
- ASSERT(isVector2(pos), 'pos must be a vec2');
947
- ASSERT(isNumber(size), 'size must be a number');
948
- ASSERT(isColor(color), 'color must be a color');
949
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
950
- ASSERT(isColor(lineColor), 'lineColor must be a color');
951
- ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
952
- ASSERT(isStringLike(font), 'font must be a string');
953
- ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
954
- ASSERT(isNumber(angle), 'angle must be a number');
955
-
956
- const lines = (text+'').split('\n');
957
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
958
- // save before style mutations so caller's context state is preserved
959
- context.save();
960
- context.fillStyle = color.toString();
961
- context.strokeStyle = lineColor.toString();
962
- context.lineWidth = lineWidth;
963
- context.textAlign = textAlign;
964
- context.font = fontStyle + ' ' + size + 'px '+ font;
965
- context.textBaseline = 'middle';
966
- context.translate(pos.x, posY);
967
- context.rotate(-angle);
968
- let yOffset = 0;
969
- lines.forEach(line=>
970
- {
971
- lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
972
- context.fillText(line, 0, yOffset, maxWidth);
973
- yOffset += size;
974
- });
975
- context.restore();
976
- }
977
-
978
- ///////////////////////////////////////////////////////////////////////////////
979
- // Drawing utilities
980
-
981
- /** Load a texture at a specific index
982
- * @param {number} textureIndex - Index to store the texture at
983
- * @param {string} [src] - Image source path
984
- * @return {Promise} Promise that resolves when texture is loaded
985
- * @memberof Draw */
986
- async function loadTexture(textureIndex, src)
987
- {
988
- ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
989
- ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
990
- ASSERT(!src || isStringLike(src), 'image src must be a string');
991
-
992
- const image = new Image;
993
- if (src)
994
- {
995
- await new Promise(resolve =>
996
- {
997
- image.onerror = image.onload = resolve;
998
- image.crossOrigin = 'anonymous';
999
- image.src = src;
1000
- });
1001
- }
1002
-
1003
- textureInfos[textureIndex] = new TextureInfo(image);
1004
- }
1005
-
1006
- /** Convert from screen to world space coordinates
1007
- * @param {Vector2} screenPos
1008
- * @return {Vector2}
1009
- * @memberof Draw */
1010
- function screenToWorld(screenPos)
1011
- {
1012
- ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1013
-
1014
- let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
1015
- let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
1016
- if (cameraAngle)
1017
- {
1018
- // apply camera rotation
1019
- const c = cos(-cameraAngle), s = sin(-cameraAngle);
1020
- const xr = x * c - y * s, yr = x * s + y * c;
1021
- x = xr; y = yr;
1022
- }
1023
- return new Vector2(x + cameraPos.x, y + cameraPos.y);
1024
- }
1025
-
1026
- /** Convert from world to screen space coordinates
1027
- * @param {Vector2} worldPos
1028
- * @return {Vector2}
1029
- * @memberof Draw */
1030
- function worldToScreen(worldPos)
1031
- {
1032
- ASSERT(isVector2(worldPos), 'worldPos must be a vec2');
1033
-
1034
- let x = worldPos.x - cameraPos.x;
1035
- let y = worldPos.y - cameraPos.y;
1036
- if (cameraAngle)
1037
- {
1038
- // apply inverse camera rotation
1039
- const c = cos(cameraAngle), s = sin(cameraAngle);
1040
- const xr = x * c - y * s, yr = x * s + y * c;
1041
- x = xr; y = yr;
1042
- }
1043
- return new Vector2
1044
- (
1045
- x * cameraScale + mainCanvasSize.x/2 - .5,
1046
- y * -cameraScale + mainCanvasSize.y/2 - .5
1047
- );
1048
- }
1049
-
1050
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1051
- * @param {Vector2} screenDelta
1052
- * @return {Vector2}
1053
- * @memberof Draw */
1054
- function screenToWorldDelta(screenDelta)
1055
- {
1056
- ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2');
1057
-
1058
- let x = screenDelta.x / cameraScale;
1059
- let y = screenDelta.y / -cameraScale;
1060
- if (cameraAngle)
1061
- {
1062
- // apply camera rotation
1063
- const c = cos(-cameraAngle), s = sin(-cameraAngle);
1064
- const xr = x * c - y * s, yr = x * s + y * c;
1065
- x = xr; y = yr;
1066
- }
1067
- return new Vector2(x, y);
1068
- }
1069
-
1070
- /** Convert from screen to world space coordinates for a directional vector (no translation)
1071
- * @param {Vector2} worldDelta
1072
- * @return {Vector2}
1073
- * @memberof Draw */
1074
- function worldToScreenDelta(worldDelta)
1075
- {
1076
- ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2');
1077
-
1078
- let x = worldDelta.x;
1079
- let y = worldDelta.y;
1080
- if (cameraAngle)
1081
- {
1082
- // apply inverse camera rotation
1083
- const c = cos(cameraAngle), s = sin(cameraAngle);
1084
- const xr = x * c - y * s, yr = x * s + y * c;
1085
- x = xr; y = yr;
1086
- }
1087
- return new Vector2(x * cameraScale, y * -cameraScale);
1088
- }
1089
-
1090
- /** Convert screen space transform to world space
1091
- * @param {Vector2} screenPos
1092
- * @param {Vector2} screenSize
1093
- * @param {number} [screenAngle]
1094
- * @return {[Vector2, Vector2, number]} - [pos, size, angle]
1095
- * @memberof Draw */
1096
- function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1097
- {
1098
- ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1099
- ASSERT(isVector2(screenSize), 'screenSize must be a vec2');
1100
- ASSERT(isNumber(screenAngle), 'screenAngle must be a number');
1101
-
1102
- return [
1103
- screenToWorld(screenPos),
1104
- screenSize.scale(1/cameraScale),
1105
- screenAngle + cameraAngle
1106
- ];
1107
- }
1108
-
1109
- /** Get the size of the camera window in world space
1110
- * @return {Vector2}
1111
- * @memberof Draw */
1112
- function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1113
-
1114
- /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1115
- * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1116
- * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
1117
- * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
1118
- * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
1119
- * or an object with any of {top, right, bottom, left}
1120
- * @param {Vector2} center - Center of the rectangle in world space
1121
- * @param {Vector2} size - Size of the rectangle in world space
1122
- * @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
1123
- * @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1124
- * @return {number} - The new camera scale
1125
- * @memberof Draw */
1126
- function cameraFit(center, size, worldMargin, screenInset)
1127
- {
1128
- ASSERT(isVector2(center), 'center must be a vec2');
1129
- ASSERT(isVector2(size), 'size must be a vec2');
1130
-
1131
- // pad the content
1132
- const margin = padSides(worldMargin);
1133
- const inset = padSides(screenInset);
1134
- const worldW = size.x + margin.left + margin.right;
1135
- const worldH = size.y + margin.top + margin.bottom;
1136
- const viewW = mainCanvasSize.x - inset.left - inset.right;
1137
- const viewH = mainCanvasSize.y - inset.top - inset.bottom;
1138
-
1139
- // bail on a degenerate rect or viewport rather than NaN the camera
1140
- if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1141
- return cameraScale;
1142
-
1143
- // scale to fit the padded content
1144
- cameraScale = min(viewW / worldW, viewH / worldH);
1145
-
1146
- // calculate offset vectors
1147
- const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
1148
- const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
1149
-
1150
- // apply the offsets and return camera scale
1151
- cameraPos = center.add(marginVector).add(insetVector);
1152
- return cameraScale;
1153
-
1154
- function padSides(p)
1155
- {
1156
- // normalize a padding option to {top, right, bottom, left}
1157
- if (p === undefined || isNumber(p))
1158
- p = vec2(p);
1159
- if (isVector2(p))
1160
- return { top: p.y, right: p.x, bottom: p.y, left: p.x };
1161
- return {
1162
- top: p.top || 0,
1163
- right: p.right || 0,
1164
- bottom: p.bottom || 0,
1165
- left: p.left || 0,
1166
- };
1167
- }
1168
- }
1169
-
1170
- /** Check if a box, point, or circle is on screen with a circle test
1171
- * If size is a Vector2, uses the length as diameter
1172
- * This can be used to cull offscreen objects from render or update
1173
- * @param {Vector2} pos - world space position
1174
- * @param {Vector2|number} size - world space size or diameter
1175
- * @return {boolean}
1176
- * @memberof Draw */
1177
- function isOnScreen(pos, size=0)
1178
- {
1179
- ASSERT(isVector2(pos), 'pos must be a vec2');
1180
- ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
1181
-
1182
- // cameraScale of 0 collapses world coords; nothing is visible
1183
- if (!cameraScale) return false;
1184
-
1185
- // optimized circle on screen test
1186
- // pos = worldToScreen(pos);
1187
- let x = pos.x - cameraPos.x;
1188
- let y = pos.y - cameraPos.y;
1189
- if (cameraAngle)
1190
- {
1191
- // apply inverse camera rotation
1192
- const c = cos(cameraAngle), s = sin(cameraAngle);
1193
- const xr = x * c - y * s, yr = x * s + y * c;
1194
- x = xr; y = yr;
1195
- }
1196
- x *= cameraScale*2; y *= -cameraScale*2;
1197
-
1198
- if (size instanceof Vector2)
1199
- size = size.length(); // use length of vector as diameter
1200
- size *= cameraScale;
1201
-
1202
- // check against screen bounds
1203
- const w = mainCanvasSize.x, h = mainCanvasSize.y;
1204
- return x + size > -w && x - size < w &&
1205
- y + size > -h && y - size < h;
1206
- }
1207
-
1208
- /** Enable additive blending
1209
- * @param {boolean} [additive]
1210
- * @memberof Draw */
1211
- function setAdditiveBlendMode(additive=true)
1212
- {
1213
- glAdditive = additive;
1214
- drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1215
- }
1216
-
1217
- /** Set an extra canvas to composite behind the engine canvases when combining
1218
- * Plugins that insert their own canvas below the LittleJS canvases should set
1219
- * this so it appears in screenshots and video capture
1220
- * @param {HTMLCanvasElement} [canvas]
1221
- * @memberof Draw */
1222
- function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
1223
-
1224
- /** Combines LittleJS canvases onto the main canvas
1225
- * This is necessary for things like screenshots and video
1226
- * @memberof Draw */
1227
- function combineCanvases()
1228
- {
1229
- const w = mainCanvasSize.x, h = mainCanvasSize.y;
1230
- workCanvas.width = w;
1231
- workCanvas.height = h;
1232
- // remove background alpha — explicit fillStyle so a previous caller
1233
- // leaving workContext.fillStyle transparent can't silently no-op this
1234
- workContext.fillStyle = '#000';
1235
- workContext.fillRect(0,0,w,h);
1236
- if (backgroundCanvas)
1237
- workContext.drawImage(backgroundCanvas, 0, 0, w, h);
1238
- glCopyToContext(workContext);
1239
- workContext.drawImage(mainCanvas, 0, 0);
1240
- mainContext.drawImage(workCanvas, 0, 0);
1241
- }
1242
-
1243
- // Internal: bake a color/additive-color tint into workReadCanvas at the
1244
- // image's native resolution. Returns the work canvas, suitable for
1245
- // passing to context.createPattern. Used by drawTextureWrapped's
1246
- // Canvas2D path. Caller is responsible for short-circuiting when no
1247
- // tint is needed (i.e. color is white and additiveColor is black/none).
1248
- function bakeTintedImage(image, color, additiveColor)
1249
- {
1250
- const w = image.width|0, h = image.height|0;
1251
- workReadCanvas.width = w;
1252
- workReadCanvas.height = h;
1253
- workReadContext.drawImage(image, 0, 0);
1254
-
1255
- const imageData = workReadContext.getImageData(0, 0, w, h);
1256
- const data = imageData.data;
1257
- if (additiveColor && !isBlack(additiveColor))
1258
- {
1259
- // multiply + additive (slower)
1260
- const colorMultiply = [color.r, color.g, color.b, color.a];
1261
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1262
- additiveColor.b * 255, additiveColor.a * 255];
1263
- for (let i = 0; i < data.length; ++i)
1264
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1265
- }
1266
- else
1267
- {
1268
- // RGB only, faster — alpha left intact for the caller
1269
- for (let i = 0; i < data.length; i+=4)
1270
- {
1271
- data[i ] *= color.r;
1272
- data[i+1] *= color.g;
1273
- data[i+2] *= color.b;
1274
- }
1275
- }
1276
- workReadContext.putImageData(imageData, 0, 0);
1277
- return workReadCanvas;
1278
- }
1279
-
1280
- /** Helper function to draw an image with color and additive color applied
1281
- * This is slower then normal drawImage when color is applied
1282
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1283
- * @param {HTMLImageElement|OffscreenCanvas} image
1284
- * @param {number} sx
1285
- * @param {number} sy
1286
- * @param {number} sWidth
1287
- * @param {number} sHeight
1288
- * @param {number} dx
1289
- * @param {number} dy
1290
- * @param {number} dWidth
1291
- * @param {number} dHeight
1292
- * @param {Color} color
1293
- * @param {Color} [additiveColor]
1294
- * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1295
- * @memberof Draw */
1296
- function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1297
- {
1298
- const sx2 = bleed;
1299
- const sy2 = bleed;
1300
- sWidth = max(1,sWidth|0);
1301
- sHeight = max(1,sHeight|0);
1302
- const sWidth2 = sWidth - 2*bleed;
1303
- const sHeight2 = sHeight - 2*bleed;
1304
- if (!canvasColorTiles || (additiveColor ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0 : isWhite(color)))
1305
- {
1306
- // white texture with no additive alpha, no need to tint
1307
- context.globalAlpha = color.a;
1308
- context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1309
- context.globalAlpha = 1;
1310
- }
1311
- else
1312
- {
1313
- // copy to offscreen canvas
1314
- workReadCanvas.width = sWidth;
1315
- workReadCanvas.height = sHeight;
1316
- workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
1317
-
1318
- // tint image using offscreen work context
1319
- const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
1320
- const data = imageData.data;
1321
- if (additiveColor && !isBlack(additiveColor))
1322
- {
1323
- // slower path with additive color
1324
- const colorMultiply = [color.r, color.g, color.b, color.a];
1325
- const colorAdd = [additiveColor.r * 255, additiveColor.g * 255, additiveColor.b * 255, additiveColor.a * 255];
1326
- for (let i = 0; i < data.length; ++i)
1327
- data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1328
- workReadContext.putImageData(imageData, 0, 0);
1329
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1330
- }
1331
- else
1332
- {
1333
- // faster path with no additive color
1334
- for (let i = 0; i < data.length; i+=4)
1335
- {
1336
- data[i ] *= color.r;
1337
- data[i+1] *= color.g;
1338
- data[i+2] *= color.b;
1339
- }
1340
- workReadContext.putImageData(imageData, 0, 0);
1341
- context.globalAlpha = color.a;
1342
- context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1343
- context.globalAlpha = 1;
1344
- }
1345
- }
1346
- }
1347
-
1348
-
1349
- /** Returns true if fullscreen mode is active
1350
- * @return {boolean}
1351
- * @memberof Draw */
1352
- function isFullscreen() { return !!document.fullscreenElement; }
1353
-
1354
- /** Toggle fullscreen mode
1355
- * @memberof Draw */
1356
- function toggleFullscreen()
1357
- {
1358
- const rootElement = mainCanvas.parentElement;
1359
- if (isFullscreen())
1360
- {
1361
- if (document.exitFullscreen)
1362
- document.exitFullscreen();
1363
- }
1364
- else if (rootElement.requestFullscreen)
1365
- rootElement.requestFullscreen();
1366
- }
1367
-
1368
- /** Set the cursor style
1369
- * @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc)
1370
- * @memberof Draw */
1371
- function setCursor(cursorStyle = 'auto')
1372
- {
1373
- const rootElement = mainCanvas.parentElement;
1374
- rootElement.style.cursor = cursorStyle;
1375
- }
1376
-
1377
- ///////////////////////////////////////////////////////////////////////////////
1378
-
1379
- /** Engine font image, 8x8 font provided by the engine
1380
- * @type {ImageFont}
1381
- * @memberof Draw */
1382
- let engineImageFont;
1383
-
1384
- /**
1385
- * Image Font Object - Draw text by using tiles in an image
1386
- * - 96 characters (from space to tilde) are stored in an image
1387
- * - A 8x8 default engine font is supplied for general use
1388
- * - This system is WebGL enabled for fast text rendering
1389
- * - Fonts can also be colored and scaled along each axis
1390
- *
1391
- * @memberof Draw
1392
- * @example
1393
- * // use built in font
1394
- * const font = engineImageFont;
1395
- *
1396
- * // draw text
1397
- * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1398
- */
1399
- class ImageFont
1400
- {
1401
- /** Create an image font
1402
- * @param {TileInfo} tileInfo - Tile info of first character in font
1403
- */
1404
- constructor(tileInfo)
1405
- {
1406
- ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1407
-
1408
- /** @property {TileInfo} - Tile info for the font */
1409
- this.tileInfo = tileInfo.frame(0);
1410
- }
1411
-
1412
- /** Draw text in world space using the image font
1413
- * @param {string|number} text
1414
- * @param {Vector2} pos
1415
- * @param {Vector2|number} [size]
1416
- * @param {boolean} [center=true]
1417
- * @param {Color} [color=WHITE]
1418
- * @param {boolean} [useWebGL=glEnable]
1419
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1420
- */
1421
- drawText(text, pos, size=1, center, color, useWebGL, context)
1422
- {
1423
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1424
-
1425
- if (typeof size === 'number')
1426
- {
1427
- // if size is a number, make it a vector
1428
- ASSERT(size > 0);
1429
- size *= cameraScale;
1430
- size = new Vector2(size, size);
1431
- }
1432
- else
1433
- size = size.scale(cameraScale);
1434
- this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1435
- }
1436
-
1437
- /** Draw text in screen space using the image font
1438
- * @param {string|number} text
1439
- * @param {Vector2} pos
1440
- * @param {Vector2|number} size
1441
- * @param {boolean} [center]
1442
- * @param {Color} [color=WHITE]
1443
- * @param {boolean} [useWebGL=glEnable]
1444
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1445
- */
1446
- drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
1447
- {
1448
- ASSERT(isStringLike(text), 'text must be a string');
1449
- ASSERT(isVector2(pos), 'pos must be a vec2');
1450
- ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1451
- ASSERT(isColor(color), 'color must be a color');
1452
-
1453
- // if size is a number, make it a vector
1454
- size = typeof size === 'number' ? new Vector2(size, size) : size;
1455
-
1456
- // precache objects for drawing
1457
- const drawPos = new Vector2;
1458
- const tileInfo = this.tileInfo;
1459
- const padding = tileInfo.padding;
1460
- const sizePaddedX = tileInfo.size.x + padding*2;
1461
- const sizePaddedY = tileInfo.size.y + padding*2;
1462
- const cols = tileInfo.textureInfo.size.x / sizePaddedX |0;
1463
-
1464
- // draw each line of text
1465
- (text+'').split('\n').forEach((line, j)=>
1466
- {
1467
- const centerOffset = center ? (line.length-1) * size.x / 2 : 0;
1468
- for (let i=line.length; i--;)
1469
- {
1470
- // get the character index
1471
- const charCode = line.charCodeAt(i);
1472
- const index = charCode < 32 || charCode > 127 ?
1473
- 95 : charCode - 32; // handle out of range characters
1474
-
1475
- // get the position of the tile
1476
- const x = index % cols;
1477
- const y = index / cols |0;
1478
- tileInfo.pos.x = x*sizePaddedX + padding;
1479
- tileInfo.pos.y = y*sizePaddedY + padding;
1480
-
1481
- // snap the glyph edges to whole pixels
1482
- // tiles are drawn from their center, so snapping the center
1483
- // to a whole pixel puts the edges on half pixels when the
1484
- // size is even, and a row or column of the glyph then has
1485
- // no pixel center inside it and is not rasterized at all
1486
- // ceil picks the nearest aligned position, breaking ties
1487
- // downward to match how this used to truncate
1488
- drawPos.x = ceil(pos.x + i * size.x - centerOffset - size.x/2) + size.x/2 - .5;
1489
- drawPos.y = ceil(pos.y + j * size.y - size.y/2) + size.y/2 - .5;
1490
- drawTile(drawPos, size, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1491
- }
1492
- });
1493
- }
1494
- }
1495
-
1496
- // load engine font, called automatically on startup
1497
- async function imageFontInit()
1498
- {
1499
- const image = new Image;
1500
- await new Promise(resolve =>
1501
- {
1502
- image.onerror = image.onload = resolve;
1503
- image.crossOrigin = 'anonymous';
1504
- image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg==';
1505
- });
1506
-
1507
- const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
1508
- const textureInfo = new TextureInfo(image);
1509
- const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
1510
- engineImageFont = new ImageFont(tileInfo);
1
+ /**
2
+ * LittleJS Drawing System
3
+ * - Hybrid rendering with both Canvas2D and WebGL support
4
+ * - Optimized tile sheet sprite rendering using WebGL batching
5
+ * - Primitive drawing for polygons, ellipses, and lines
6
+ * - Tile-based rendering with TileInfo and TextureInfo classes
7
+ * - Text rendering with custom fonts and ImageFont support
8
+ * - Color and additive color blending for effects
9
+ * - Rotation, mirroring, and scaling transformations
10
+ * - Camera system with position, scale, and rotation
11
+ * - Multiple canvas support (main, WebGL, work canvases)
12
+ * - Gradient fills and outlined shapes
13
+ * - Image manipulation and color tinting
14
+ *
15
+ * Rendering Architecture:
16
+ * - glCanvas: WebGL canvas for accelerated sprite batch rendering
17
+ * - mainCanvas: Canvas2D overlay for text, UI, and custom drawing
18
+ * - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter
19
+ *
20
+ * @namespace Draw
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ /** The primary 2D canvas visible to the user
26
+ * @type {HTMLCanvasElement}
27
+ * @memberof Draw */
28
+ let mainCanvas;
29
+
30
+ /** 2d context for mainCanvas
31
+ * - Scaled by canvasPixelRatio, so drawing to it is in css pixels
32
+ * - getImageData and putImageData ignore that scale and work in backing store
33
+ * pixels, so use workReadCanvas to read pixels back instead of this
34
+ * @type {CanvasRenderingContext2D}
35
+ * @memberof Draw */
36
+ let mainContext;
37
+
38
+ /** The default 2d context to use for drawing, usually mainContext
39
+ * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
40
+ * @memberof Draw */
41
+ let drawContext;
42
+
43
+ /** Offscreen canvas that can be used for image processing
44
+ * @type {OffscreenCanvas}
45
+ * @memberof Draw */
46
+ let workCanvas;
47
+
48
+ /** Offscreen canvas that can be used for image processing
49
+ * @type {OffscreenCanvasRenderingContext2D}
50
+ * @memberof Draw */
51
+ let workContext;
52
+
53
+ /** Offscreen canvas with willReadFrequently that can be used for image processing
54
+ * @type {OffscreenCanvas}
55
+ * @memberof Draw */
56
+ let workReadCanvas;
57
+
58
+ /** Offscreen canvas with willReadFrequently that can be used for image processing
59
+ * @type {OffscreenCanvasRenderingContext2D}
60
+ * @memberof Draw */
61
+ let workReadContext;
62
+
63
+ /** Extra canvas to composite behind the engine canvases when combining canvases
64
+ * Set by plugins that render to their own canvas below the LittleJS canvases
65
+ * @type {HTMLCanvasElement}
66
+ * @memberof Draw */
67
+ let backgroundCanvas;
68
+
69
+ /** The size of the main canvas (and other secondary canvases) in css pixels
70
+ * - This is the screen space coordinate system, matching mousePos
71
+ * - With canvasPixelRatio set the backing store is larger than this
72
+ * @type {Vector2}
73
+ * @memberof Draw */
74
+ let mainCanvasSize = vec2();
75
+
76
+ /** Array containing texture info for batch rendering system
77
+ * @type {Array<TextureInfo>}
78
+ * @memberof Draw */
79
+ let textureInfos = [];
80
+
81
+ /** Keeps track of how many draw calls there were each frame for debugging
82
+ * @type {number}
83
+ * @memberof Draw */
84
+ let drawCount = 0;
85
+
86
+ /** Keeps track of how many primitives were drawn each frame for debugging
87
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
88
+ * @type {number}
89
+ * @memberof Draw */
90
+ let primitiveCount = 0;
91
+
92
+ // internal predicates for tint short-circuiting in canvas2D draw paths
93
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
94
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
95
+ /** @ignore
96
+ * @param {Color} c */
97
+ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
98
+ /** @ignore
99
+ * @param {Color} c */
100
+ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
101
+
102
+ ///////////////////////////////////////////////////////////////////////////////
103
+
104
+ /**
105
+ * Create a tile info object using a grid based system
106
+ * - This can take vecs or floats for easier use and conversion
107
+ * - If an index is passed in, the tile size and index will determine the position
108
+ * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
109
+ * @param {Vector2|number} [size] - Size of tile in pixels
110
+ * @param {TextureInfo|number} [texture] - Texture index or info to use
111
+ * @param {number} [padding] - How many pixels padding around tiles
112
+ * @param {number} [bleed] - How many pixels smaller to draw tiles
113
+ * @return {TileInfo}
114
+ * @example
115
+ * tile(2) // a tile at index 2 using the default tile size of 16
116
+ * tile(5, 8) // a tile at index 5 using a tile size of 8
117
+ * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
118
+ * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
119
+ * @memberof Draw */
120
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
121
+ {
122
+ ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
123
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
124
+ ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
125
+ ASSERT(isNumber(padding), 'padding must be a number');
126
+
127
+ if (typeof size === 'number')
128
+ {
129
+ // if size is a number, make it a vector
130
+ ASSERT(size > 0);
131
+ size = new Vector2(size, size);
132
+ }
133
+
134
+ // create tile info object
135
+ const textureInfo = typeof texture === 'number' ?
136
+ textureInfos[texture] : texture;
137
+ if (headlessMode && !textureInfo?.size.x)
138
+ return new TileInfo(new Vector2, size.copy(), textureInfo, padding, bleed); // no image loaded, no place in it
139
+ ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
140
+ ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
141
+
142
+ // get the position of the tile
143
+ const sizePaddedX = size.x + padding*2;
144
+ const sizePaddedY = size.y + padding*2;
145
+ let x, y;
146
+ if (typeof index === 'number')
147
+ {
148
+ const cols = textureInfo.size.x / sizePaddedX |0;
149
+ x = index % cols;
150
+ y = index / cols |0;
151
+ }
152
+ else
153
+ {
154
+ x = index.x;
155
+ y = index.y;
156
+ }
157
+ const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding);
158
+ return new TileInfo(pos, size, textureInfo, padding, bleed);
159
+ }
160
+
161
+ /**
162
+ * Tile Info - Stores info about how to draw a tile
163
+ * @memberof Draw
164
+ */
165
+ class TileInfo
166
+ {
167
+ /** Create a tile info object
168
+ * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
169
+ * @param {Vector2} [size] - Size of tile in pixels
170
+ * @param {TextureInfo} [textureInfo] - Texture info to use
171
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
172
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
173
+ * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
174
+ */
175
+ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
176
+ {
177
+ /** @property {Vector2} - Top left corner of tile in pixels */
178
+ this.pos = pos.copy();
179
+ /** @property {Vector2} - Size of tile in pixels */
180
+ this.size = size.copy();
181
+ /** @property {number} - How many pixels padding around tiles */
182
+ this.padding = padding;
183
+ /** @property {TextureInfo} - The texture info for this tile */
184
+ this.textureInfo = textureInfo;
185
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
186
+ this.bleed = bleed;
187
+ /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
188
+ this.columns = columns;
189
+ }
190
+
191
+ /** Returns a copy of this tile offset by a vector
192
+ * @param {Vector2} offset - Offset to apply in pixels
193
+ * @return {TileInfo}
194
+ */
195
+ offset(offset)
196
+ { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
197
+
198
+ /** Returns a copy of this tile offset by a number of animation frames
199
+ * Frames wrap down to the next row if columns is set
200
+ * @param {number} frame - Offset to apply in animation frames
201
+ * @return {TileInfo}
202
+ */
203
+ frame(frame)
204
+ {
205
+ ASSERT(typeof frame === 'number');
206
+ const w = this.size.x + this.padding*2;
207
+ const h = this.size.y + this.padding*2;
208
+ const x = (this.columns ? frame % this.columns : frame) * w;
209
+ const y = (this.columns ? frame / this.columns | 0 : 0) * h;
210
+ ASSERT(!this.textureInfo || this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
211
+ ASSERT(!this.textureInfo || this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
212
+ return this.offset(new Vector2(x, y));
213
+ }
214
+
215
+ /** Set how many frames per row this tile uses, so frame() can wrap
216
+ * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
217
+ * @return {TileInfo}
218
+ */
219
+ setColumns(columns=0)
220
+ {
221
+ ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
222
+ this.columns = columns;
223
+ return this;
224
+ }
225
+
226
+ /**
227
+ * Returns a tile info for an index using this tile's size, texture and padding as reference
228
+ * - the index counts from the texture's origin like tile(), for a sprite packed in a texture sheet use frame()
229
+ * @param {Vector2|number} [index=0]
230
+ * @return {TileInfo}
231
+ */
232
+ index(index)
233
+ { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
234
+
235
+ /**
236
+ * Set this tile to use a full image in a texture info
237
+ * @param {TextureInfo} [textureInfo]
238
+ * @return {TileInfo}
239
+ */
240
+ setFullImage(textureInfo=this.textureInfo)
241
+ {
242
+ this.textureInfo = textureInfo;
243
+ this.pos = new Vector2;
244
+ this.size = textureInfo.size.copy();
245
+ this.bleed = this.padding = this.columns = 0;
246
+ return this;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Texture Info - Stores info about each texture
252
+ * @memberof Draw
253
+ */
254
+ class TextureInfo
255
+ {
256
+ /**
257
+ * Create a TextureInfo, called automatically by the engine
258
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
259
+ * @param {boolean} [useWebGL] - Should use WebGL if available?
260
+ * @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
261
+ * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
262
+ */
263
+ constructor(image, useWebGL=true, wrap=false)
264
+ {
265
+ /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
266
+ this.image = image;
267
+ /** @property {Vector2} - size of the image */
268
+ this.size = image ? vec2(image.width, image.height) : vec2();
269
+ /** @property {Vector2} - inverse of the size, cached for rendering */
270
+ this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
271
+ /** @property {WebGLTexture|undefined} - WebGL texture
272
+ * @type {WebGLTexture|undefined} */
273
+ this.glTexture = undefined;
274
+ /** @property {boolean|Array<number>} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE, or the WebGL modes
275
+ * across and down
276
+ * @type {boolean|Array<number>} */
277
+ this.wrap = wrap;
278
+ useWebGL && this.createWebGLTexture();
279
+ }
280
+
281
+ /** Creates the WebGL texture, updates if already created */
282
+ createWebGLTexture()
283
+ {
284
+ const image = this.image;
285
+ if (image?.width) // the image may have been resized since
286
+ {
287
+ this.size = vec2(image.width, image.height);
288
+ this.sizeInverse = vec2(1/image.width, 1/image.height);
289
+ }
290
+ glRegisterTextureInfo(this);
291
+ }
292
+
293
+ /** Destroys the WebGL texture */
294
+ destroyWebGLTexture() { glUnregisterTextureInfo(this); }
295
+
296
+ /** Check if the texture is webgl enabled
297
+ * @return {boolean} */
298
+ hasWebGL() { return !!this.glTexture; }
299
+
300
+ /** Set the wrap mode for this texture
301
+ * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across
302
+ * and down */
303
+ setWrap(wrap=true)
304
+ {
305
+ this.wrap = wrap;
306
+ glSetTextureWrap(this.glTexture, wrap);
307
+ }
308
+ }
309
+
310
+ ///////////////////////////////////////////////////////////////////////////////
311
+ /**
312
+ * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
313
+ * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
314
+ * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
315
+ * - loop, play and pingPong each start over from the first frame; stop holds the current one
316
+ * - Frames follow each other along the row, as tileInfo.frame counts them
317
+ * @example
318
+ * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
319
+ * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
320
+ * // in update: this.tileInfo = (attack.isDone ? walk : attack).tileInfo;
321
+ * @memberof Draw
322
+ */
323
+ class SpriteAnimation
324
+ {
325
+ /** Create an animation over a run of frames, looping from the start
326
+ * @param {TileInfo} tileInfo - The first frame
327
+ * @param {number} frameCount - How many frames, one or more
328
+ * @param {number} [frameTime] - Seconds each frame shows for */
329
+ constructor(tileInfo, frameCount, frameTime=.1)
330
+ {
331
+ ASSERT(tileInfo instanceof TileInfo, 'the first frame must be a TileInfo');
332
+ ASSERT(frameCount >= 1 && frameTime > 0, 'an animation needs at least one frame and a positive frame time');
333
+ /** @property {TileInfo} - The first frame, the others follow it along the row */
334
+ this.firstTile = tileInfo;
335
+ /** @property {number} - How many frames */
336
+ this.frameCount = frameCount;
337
+ /** @property {number} - Seconds each frame shows for */
338
+ this.frameTime = frameTime;
339
+ /** @property {number} - Rate multiplier, 2 plays twice as fast; set it before starting */
340
+ this.speed = 1;
341
+ /** @property {string} - How it runs: 'loop', 'once' or 'pingPong', set by loop, play and pingPong */
342
+ this.mode = 'loop';
343
+ /** @property {number} - Engine time it started at */
344
+ this.startTime = time;
345
+ /** @property {number|undefined} - The frame held by stop, undefined while running
346
+ * @type {number|undefined} */
347
+ this.heldFrame = undefined;
348
+ }
349
+
350
+ /** Start over from the first frame and repeat forever
351
+ * @return {SpriteAnimation} */
352
+ loop() { return this.restart('loop'); }
353
+
354
+ /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
355
+ * @return {SpriteAnimation} */
356
+ play() { return this.restart('once'); }
357
+
358
+ /** Start over from the first frame and run there and back forever
359
+ * @return {SpriteAnimation} */
360
+ pingPong() { return this.restart('pingPong'); }
361
+
362
+ /** Hold the current frame
363
+ * @return {SpriteAnimation} */
364
+ stop() { this.heldFrame = this.frame; return this; }
365
+
366
+ /** Start over from the first frame in a mode
367
+ * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
368
+ * @return {SpriteAnimation} */
369
+ restart(mode=this.mode)
370
+ {
371
+ this.mode = mode;
372
+ this.startTime = time;
373
+ this.heldFrame = undefined;
374
+ return this;
375
+ }
376
+
377
+ /** How many frames have gone by since the start, fractional
378
+ * @return {number} */
379
+ get elapsedFrames() { return (time - this.startTime) * this.speed / this.frameTime; }
380
+
381
+ /** The frame showing now, 0 to frameCount-1
382
+ * @return {number} */
383
+ get frame()
384
+ {
385
+ if (this.heldFrame !== undefined)
386
+ return this.heldFrame;
387
+ const n = this.frameCount, f = floor(this.elapsedFrames);
388
+ if (this.mode == 'once') // backward it counts down from the last frame, showing it from the start
389
+ return clamp(this.speed < 0 ? n - 1 + ceil(this.elapsedFrames) : f, 0, n - 1);
390
+ if (this.mode == 'loop')
391
+ return mod(f, n);
392
+ const period = max(2 * n - 2, 1), k = mod(f, period); // there and back, the ends once each
393
+ return k < n ? k : period - k;
394
+ }
395
+
396
+ /** The tile of the frame showing now
397
+ * @return {TileInfo} */
398
+ get tileInfo() { return this.firstTile.frame(this.frame); }
399
+
400
+ /** True once a play has shown its last frame for its time
401
+ * @return {boolean} */
402
+ get isDone() { return this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; }
403
+ }
404
+
405
+ ///////////////////////////////////////////////////////////////////////////////
406
+ /**
407
+ * Shader - A custom fragment shader for objects and draws, 2D or 3D
408
+ * - Write a mainImage function in the post processing style, the renderer wraps it with its own program
409
+ * - It gives the surface color, then the object's color and additive color apply in 2D, and the lighting,
410
+ * shadows and fog in 3D; set emissive to 1 on a 3D object for the snippet's color to be final
411
+ * - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws
412
+ * - Draws that share a Shader share a batch; with no Shader set nothing changes
413
+ * - In 2D it shades textured draws, untextured ones like drawRect draw as they are
414
+ * - A render target, like a tile layer drawn in WebGL, holds premultiplied color, and so does every image when
415
+ * tilesPixelated is false; there iChannel0 reads premultiplied texels and the snippet's color is taken as
416
+ * premultiplied too. premultipliedTexture is true there, so a snippet that changes the alpha, or makes a see
417
+ * through color of its own, scales the rgb with it: `if (premultipliedTexture) c.rgb *= k;`
418
+ * - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug
419
+ * - Make each Shader once, at init, and share it; every one made lives for the session with its programs
420
+ * - Names in both renderers: iChannel0 the texture, iTime, iResolution, premultipliedTexture, and localUV, 0 to 1
421
+ * across the sprite or the mesh's own uv
422
+ * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
423
+ * lightCount, lights[i], lightColors[i] and shadow()
424
+ * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
425
+ * parts of a surface still shadows with the whole of it
426
+ * @example
427
+ * const fade = new Shader(`
428
+ * void mainImage(out vec4 c, vec2 uv)
429
+ * {
430
+ * c = texture(iChannel0, uv);
431
+ * float fade = .5 + .5*sin(iTime);
432
+ * c.a *= fade;
433
+ * if (premultipliedTexture) c.rgb *= fade; // a tile layer, or any image with tilesPixelated false
434
+ * }`);
435
+ * obj.shader = fade;
436
+ * @memberof Draw
437
+ */
438
+ class Shader
439
+ {
440
+ /** Create a shader from a fragment snippet that defines void mainImage(out vec4 c, vec2 uv)
441
+ * @param {string} fragmentCode */
442
+ constructor(fragmentCode)
443
+ {
444
+ ASSERT(isStringLike(fragmentCode) && String(fragmentCode).includes('mainImage'), 'a Shader needs fragment code that defines mainImage');
445
+ /** @property {string} - The mainImage snippet */
446
+ this.fragmentCode = String(fragmentCode);
447
+ /** @property {WebGLProgram|undefined} - The 2D program, compiled by the first draw that needs it, read only
448
+ * @type {WebGLProgram|undefined} */
449
+ this.program = undefined;
450
+ /** @property {WebGLProgram|undefined} - The 3D program, compiled by the 3D plugin the same way, read only
451
+ * @type {WebGLProgram|undefined} */
452
+ this.program3D = undefined;
453
+ glShaderObjects.push(this); // a lost context drops the programs of every one
454
+ }
455
+ }
456
+
457
+ ///////////////////////////////////////////////////////////////////////////////
458
+ // Drawing functions
459
+
460
+ /** Draw textured tile centered in world space
461
+ * @param {Vector2} pos - Center of the tile in world space
462
+ * @param {Vector2} [size=vec2(1)] - Size of the tile in world space
463
+ * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
464
+ * @param {Color} [color=WHITE] - Color to modulate with
465
+ * @param {number} [angle] - Angle to rotate by
466
+ * @param {boolean} [mirror] - Is image flipped along the Y axis?
467
+ * @param {Color} [additiveColor] - Additive color to be applied if any
468
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
469
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
470
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
471
+ * @memberof Draw */
472
+ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
473
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
474
+ {
475
+ ASSERT(isVector2(pos), 'pos must be a vec2');
476
+ ASSERT(isVector2(size), 'size must be a vec2');
477
+ ASSERT(isColor(color), 'color is invalid');
478
+ ASSERT(isNumber(angle), 'angle must be a number');
479
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
480
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
481
+
482
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
483
+
484
+ const textureInfo = tileInfo?.textureInfo;
485
+ if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
486
+ return; // a tile with no area draws nothing, like a sprite still loading
487
+ const bleed = tileInfo?.bleed ?? 0;
488
+ if (useWebGL && glEnable)
489
+ {
490
+ ASSERT(!!glContext, 'WebGL is not enabled!');
491
+ if (screenSpace)
492
+ {
493
+ if (glSkipScreenSpace) return;
494
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
495
+ }
496
+ if (textureInfo)
497
+ {
498
+ ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
499
+ // calculate uvs and render
500
+ const sizeInverse = textureInfo.sizeInverse;
501
+ const x = tileInfo.pos.x * sizeInverse.x;
502
+ const y = tileInfo.pos.y * sizeInverse.y;
503
+ const w = tileInfo.size.x * sizeInverse.x;
504
+ const h = tileInfo.size.y * sizeInverse.y;
505
+ glSetTexture(textureInfo.glTexture);
506
+ if (bleed)
507
+ {
508
+ const bleedX = sizeInverse.x*bleed;
509
+ const bleedY = sizeInverse.y*bleed;
510
+ glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
511
+ x + bleedX, y + bleedY,
512
+ x - bleedX + w, y - bleedY + h,
513
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
514
+ }
515
+ else
516
+ {
517
+ glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
518
+ x, y, x + w, y + h,
519
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
520
+ }
521
+ }
522
+ else
523
+ {
524
+ // untextured: color plus additive in one color, as the Canvas2D path below does
525
+ const combined = additiveColor ? color.add(additiveColor) : color;
526
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
527
+ }
528
+ }
529
+ else
530
+ {
531
+ // normal canvas 2D rendering method (slower)
532
+ ++drawCount;
533
+ ++primitiveCount;
534
+ drawCanvas2D(pos, size, angle, mirror, (context)=>
535
+ {
536
+ if (textureInfo)
537
+ {
538
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
539
+ context.scale(1, -1);
540
+ // calculate uvs and render
541
+ const x = tileInfo.pos.x, y = tileInfo.pos.y;
542
+ const w = tileInfo.size.x, h = tileInfo.size.y;
543
+ drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed);
544
+ }
545
+ else
546
+ {
547
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
548
+ const c = additiveColor ? color.add(additiveColor) : color;
549
+ context.fillStyle = c.toString();
550
+ context.fillRect(-.5, -.5, 1, 1);
551
+ }
552
+ }, screenSpace, context);
553
+ }
554
+ }
555
+
556
+ /** Draw colored rect centered on pos
557
+ * @param {Vector2} pos
558
+ * @param {Vector2} [size=vec2(1)]
559
+ * @param {Color} [color=WHITE]
560
+ * @param {number} [angle]
561
+ * @param {boolean} [useWebGL=glEnable]
562
+ * @param {boolean} [screenSpace=drawScreenSpace]
563
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
564
+ * @memberof Draw */
565
+ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
566
+ {
567
+ drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context);
568
+ }
569
+
570
+ /** Draw a rect centered on pos with a gradient from top to bottom
571
+ * @param {Vector2} pos
572
+ * @param {Vector2} [size=vec2(1)]
573
+ * @param {Color} [colorTop=WHITE]
574
+ * @param {Color} [colorBottom=CLEAR_WHITE]
575
+ * @param {number} [angle]
576
+ * @param {boolean} [useWebGL=glEnable]
577
+ * @param {boolean} [screenSpace=drawScreenSpace]
578
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
579
+ * @memberof Draw */
580
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
581
+ {
582
+ ASSERT(isVector2(pos), 'pos must be a vec2');
583
+ ASSERT(isVector2(size), 'size must be a vec2');
584
+ ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
585
+ ASSERT(isNumber(angle), 'angle must be a number');
586
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
587
+
588
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
589
+
590
+ if (useWebGL && glEnable)
591
+ {
592
+ ASSERT(!!glContext, 'WebGL is not enabled!');
593
+ if (screenSpace)
594
+ {
595
+ if (glSkipScreenSpace) return;
596
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
597
+ }
598
+ // build 4 corner points for the rectangle
599
+ const points = [], colors = [];
600
+ const halfSizeX = size.x/2, halfSizeY = size.y/2;
601
+ const colorTopInt = colorTop.rgbaInt();
602
+ const colorBottomInt = colorBottom.rgbaInt();
603
+ const c = cos(-angle), s = sin(-angle);
604
+ for (let i=4; i--;)
605
+ {
606
+ const x = i & 1 ? halfSizeX : -halfSizeX;
607
+ const y = i & 2 ? halfSizeY : -halfSizeY;
608
+ const rx = x * c - y * s;
609
+ const ry = x * s + y * c;
610
+ const color = i & 2 ? colorTopInt : colorBottomInt;
611
+ points.push(vec2(pos.x + rx, pos.y + ry));
612
+ colors.push(color);
613
+ }
614
+ glDrawColoredPoints(points, colors);
615
+ }
616
+ else
617
+ {
618
+ // normal canvas 2D rendering method (slower)
619
+ ++drawCount;
620
+ ++primitiveCount;
621
+ drawCanvas2D(pos, size, angle, false, (context)=>
622
+ {
623
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
624
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
625
+ gradient.addColorStop(0, colorTop.toString());
626
+ gradient.addColorStop(1, colorBottom.toString());
627
+ context.fillStyle = gradient;
628
+ context.fillRect(-.5, -.5, 1, 1);
629
+ }, screenSpace, context);
630
+ }
631
+ }
632
+
633
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
634
+ * Useful for backgrounds, repeating patterns, and seamless fills.
635
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
636
+ * @param {Vector2} pos - Center of the rect in world space
637
+ * @param {Vector2} size - Size of the rect in world space
638
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
639
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
640
+ * @param {Color} [color=WHITE] - Color to modulate with
641
+ * @param {number} [angle=0] - Angle to rotate by
642
+ * @param {Color} [additiveColor] - Additive color to be applied if any
643
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
644
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
645
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
646
+ * @memberof Draw */
647
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
648
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
649
+ {
650
+ ASSERT(isVector2(pos), 'pos must be a vec2');
651
+ ASSERT(isVector2(size), 'size must be a vec2');
652
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
653
+ ASSERT(isColor(color), 'color is invalid');
654
+ ASSERT(isNumber(angle), 'angle must be a number');
655
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
656
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
657
+ ASSERT(!(texture instanceof TileInfo),
658
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
659
+
660
+ // only a context passed in is drawn to in headless mode, before the texture lookup, textureInfos is empty there
661
+ if (headlessMode && !context) return;
662
+
663
+ // resolve texture argument: TextureInfo or index
664
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
665
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
666
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
667
+ ASSERT(textureInfo.wrap,
668
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
669
+
670
+ if (useWebGL && glEnable)
671
+ {
672
+ ASSERT(!!glContext, 'WebGL is not enabled!');
673
+ if (screenSpace)
674
+ {
675
+ if (glSkipScreenSpace) return;
676
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
677
+ }
678
+ glSetTexture(textureInfo.glTexture);
679
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
680
+ 0, 0, wrapCount.x, wrapCount.y,
681
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
682
+ return;
683
+ }
684
+
685
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
686
+ ++drawCount;
687
+ ++primitiveCount;
688
+
689
+ if (!screenSpace)
690
+ {
691
+ pos = worldToScreen(pos);
692
+ size = size.scale(cameraScale);
693
+ angle -= cameraAngle;
694
+ }
695
+
696
+ // pick image source: raw, or tinted bake. Match drawImageColor's
697
+ // "no tint needed" predicate so behavior stays consistent.
698
+ const noTint = !canvasColorTiles ||
699
+ isWhite(color) && (!additiveColor || isBlack(additiveColor));
700
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
701
+ // in that case globalAlpha must NOT also apply color.a
702
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
703
+ const source = noTint
704
+ ? textureInfo.image
705
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
706
+
707
+ context = context || drawContext;
708
+ context.save();
709
+ context.translate(pos.x + .5, pos.y + .5);
710
+ context.rotate(angle);
711
+ context.globalAlpha = alphaBaked ? 1 : color.a;
712
+
713
+ const pattern = context.createPattern(source, 'repeat');
714
+ // map pattern-source pixels into user space so the rect contains
715
+ // wrapCount.x × wrapCount.y repeats
716
+ const m = new DOMMatrix()
717
+ .translate(-size.x/2, -size.y/2)
718
+ .scale(size.x / (wrapCount.x * source.width),
719
+ size.y / (wrapCount.y * source.height));
720
+ pattern.setTransform(m);
721
+ context.fillStyle = pattern;
722
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
723
+ context.globalAlpha = 1;
724
+ context.restore();
725
+ }
726
+
727
+ /** Draw connected lines between a series of points
728
+ * @param {Array<Vector2>} points
729
+ * @param {number} [width]
730
+ * @param {Color} [color=WHITE]
731
+ * @param {boolean} [wrap] - Should the last point connect to the first?
732
+ * @param {Vector2} [pos=vec2()] - Offset to apply
733
+ * @param {number} [angle] - Angle to rotate by
734
+ * @param {boolean} [useWebGL=glEnable]
735
+ * @param {boolean} [screenSpace=drawScreenSpace]
736
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
737
+ * @memberof Draw */
738
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
739
+ {
740
+ ASSERT(isArray(points), 'points must be an array');
741
+ ASSERT(isNumber(width), 'width must be a number');
742
+ ASSERT(isColor(color), 'color is invalid');
743
+ ASSERT(isVector2(pos), 'pos must be a vec2');
744
+ ASSERT(isNumber(angle), 'angle must be a number');
745
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
746
+
747
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
748
+
749
+ if (useWebGL && glEnable)
750
+ {
751
+ ASSERT(!!glContext, 'WebGL is not enabled!');
752
+ let size = vec2(1);
753
+ if (screenSpace)
754
+ {
755
+ if (glSkipScreenSpace) return;
756
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
757
+ }
758
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
759
+ }
760
+ else
761
+ {
762
+ // normal canvas 2D rendering method (slower)
763
+ ++drawCount;
764
+ ++primitiveCount;
765
+ drawCanvas2D(pos, vec2(1), angle, false, (context)=>
766
+ {
767
+ // Canvas2D ignores a width of 0 or below and keeps the last one, WebGL draws none for 0
768
+ // and a negative width as its size, the outline turned inside out
769
+ if (!width) return;
770
+ context.strokeStyle = color.toString();
771
+ context.lineWidth = abs(width);
772
+ context.beginPath();
773
+ for (let i=0; i<points.length; ++i)
774
+ {
775
+ const point = points[i];
776
+ context.lineTo(point.x, point.y);
777
+ }
778
+ wrap && context.closePath();
779
+ context.stroke();
780
+ }, screenSpace, context);
781
+ }
782
+ }
783
+
784
+ /** Draw colored line between two points
785
+ * @param {Vector2} posA
786
+ * @param {Vector2} posB
787
+ * @param {number} [width]
788
+ * @param {Color} [color=WHITE]
789
+ * @param {Vector2} [pos=vec2()] - Offset to apply
790
+ * @param {number} [angle] - Angle to rotate by
791
+ * @param {boolean} [useWebGL=glEnable]
792
+ * @param {boolean} [screenSpace=drawScreenSpace]
793
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
794
+ * @memberof Draw */
795
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
796
+ {
797
+ const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
798
+ const size = vec2(width, halfDelta.length()*2);
799
+ const middle = posA.add(halfDelta);
800
+ pos = pos.add(angle ? middle.rotate(screenSpace ? -angle : angle) : middle); // screen y is down, so it turns back
801
+ if (screenSpace)
802
+ halfDelta.y *= -1; // flip angle Y if screen space
803
+ angle += halfDelta.angle();
804
+ drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
805
+ }
806
+
807
+ /** Draw colored regular polygon using passed in number of sides
808
+ * @param {Vector2} pos
809
+ * @param {Vector2} [size=vec2(1)]
810
+ * @param {number} [sides]
811
+ * @param {Color} [color=WHITE]
812
+ * @param {number} [lineWidth]
813
+ * @param {Color} [lineColor=BLACK]
814
+ * @param {number} [angle]
815
+ * @param {boolean} [useWebGL=glEnable]
816
+ * @param {boolean} [screenSpace=drawScreenSpace]
817
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
818
+ * @memberof Draw */
819
+ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
820
+ {
821
+ ASSERT(isVector2(size), 'size must be a vec2');
822
+ ASSERT(isNumber(sides), 'sides must be a number');
823
+
824
+ // build regular polygon points
825
+ const points = [];
826
+ const sizeX = size.x/2, sizeY = size.y/2;
827
+ for (let i=sides; i--;)
828
+ {
829
+ const a = (i/sides)*PI*2;
830
+ points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
831
+ }
832
+ drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
833
+ }
834
+
835
+ /** Draw colored polygon using passed in points
836
+ * - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its
837
+ * outer points, fills wrong there, so draw it as its simple outline or in parts
838
+ * @param {Array<Vector2>} points - Array of Vector2 points
839
+ * @param {Color} [color=WHITE]
840
+ * @param {number} [lineWidth]
841
+ * @param {Color} [lineColor=BLACK]
842
+ * @param {Vector2} [pos=vec2()] - Offset to apply
843
+ * @param {number} [angle] - Angle to rotate by
844
+ * @param {boolean} [useWebGL=glEnable]
845
+ * @param {boolean} [screenSpace=drawScreenSpace]
846
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
847
+ * @memberof Draw */
848
+ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context=undefined)
849
+ {
850
+ ASSERT(isVector2(pos), 'pos must be a vec2');
851
+ ASSERT(isArray(points), 'points must be an array');
852
+ ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
853
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
854
+ ASSERT(isNumber(angle), 'angle must be a number');
855
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
856
+
857
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
858
+
859
+ if (useWebGL && glEnable)
860
+ {
861
+ ASSERT(!!glContext, 'WebGL is not enabled!');
862
+ let size = vec2(1);
863
+ if (screenSpace)
864
+ {
865
+ if (glSkipScreenSpace) return;
866
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
867
+ }
868
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
869
+ if (lineWidth > 0)
870
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
871
+ }
872
+ else
873
+ {
874
+ ++drawCount;
875
+ ++primitiveCount;
876
+ drawCanvas2D(pos, vec2(1), angle, false, context=>
877
+ {
878
+ context.fillStyle = color.toString();
879
+ context.beginPath();
880
+ for (const point of points)
881
+ context.lineTo(point.x, point.y);
882
+ context.closePath();
883
+ context.fill();
884
+ if (lineWidth > 0) // as in WebGL, Canvas2D would stroke a width below 0 with the last one
885
+ {
886
+ context.strokeStyle = lineColor.toString();
887
+ context.lineWidth = lineWidth;
888
+ context.stroke();
889
+ }
890
+ }, screenSpace, context);
891
+ }
892
+ }
893
+
894
+ // the unit rings drawEllipse fills with in WebGL, one for each side count
895
+ const drawEllipseRings = new Map;
896
+
897
+ /** Draw colored ellipse using passed in point
898
+ * @param {Vector2} pos
899
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
900
+ * @param {Color} [color=WHITE]
901
+ * @param {number} [angle]
902
+ * @param {number} [lineWidth]
903
+ * @param {Color} [lineColor=BLACK]
904
+ * @param {boolean} [useWebGL=glEnable]
905
+ * @param {boolean} [screenSpace=drawScreenSpace]
906
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
907
+ * @memberof Draw */
908
+ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
909
+ {
910
+ ASSERT(isVector2(pos), 'pos must be a vec2');
911
+ ASSERT(isVector2(size), 'size must be a vec2');
912
+ ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
913
+ ASSERT(isNumber(angle), 'angle must be a number');
914
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
915
+ ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
916
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
917
+
918
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
919
+
920
+ // clamp line width to prevent artifacts, a negative size draws mirrored
921
+ lineWidth = clamp(lineWidth, 0, min(abs(size.x), abs(size.y)));
922
+
923
+ if (useWebGL && glEnable)
924
+ {
925
+ const sides = glCircleSides;
926
+ if (lineWidth > 0)
927
+ {
928
+ // draw as a regular polygon, the outline is made from the points at their size
929
+ drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
930
+ return;
931
+ }
932
+
933
+ // a fill is a unit ring scaled, made once for each side count and already in strip order
934
+ let ring = drawEllipseRings.get(sides);
935
+ if (!ring)
936
+ {
937
+ const points = [];
938
+ for (let i=sides; i--;)
939
+ {
940
+ const a = (i/sides)*PI*2;
941
+ points.push(vec2(sin(a), cos(a)));
942
+ }
943
+ drawEllipseRings.set(sides, ring = glPolyStrip(points));
944
+ }
945
+ if (screenSpace)
946
+ {
947
+ if (glSkipScreenSpace) return;
948
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
949
+ }
950
+ glDrawPointsTransform(ring, color.rgbaInt(), pos.x, pos.y, size.x/2, size.y/2, angle, false);
951
+ }
952
+ else
953
+ {
954
+ ++drawCount;
955
+ ++primitiveCount;
956
+ drawCanvas2D(pos, vec2(1), angle, false, context=>
957
+ {
958
+ context.fillStyle = color.toString();
959
+ context.beginPath();
960
+ context.ellipse(0, 0, abs(size.x)/2, abs(size.y)/2, 0, 0, 9); // it throws on a negative radius
961
+ context.fill();
962
+ if (lineWidth > 0)
963
+ {
964
+ context.strokeStyle = lineColor.toString();
965
+ context.lineWidth = lineWidth;
966
+ context.stroke();
967
+ }
968
+ }, screenSpace, context);
969
+ }
970
+ }
971
+
972
+ /** Draw colored circle using passed in point
973
+ * @param {Vector2} pos
974
+ * @param {number} [size=1] - Diameter
975
+ * @param {Color} [color=WHITE]
976
+ * @param {number} [lineWidth=0]
977
+ * @param {Color} [lineColor=BLACK]
978
+ * @param {boolean} [useWebGL=glEnable]
979
+ * @param {boolean} [screenSpace=drawScreenSpace]
980
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
981
+ * @memberof Draw */
982
+ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
983
+ {
984
+ ASSERT(isNumber(size), 'size must be a number');
985
+ drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
986
+ }
987
+
988
+ let drawEllipseGradientOffset = 0;
989
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
990
+ * - Best when batched with other untextured polys
991
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
992
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
993
+ * @param {Vector2} pos
994
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
995
+ * @param {Color} [colorInner=WHITE]
996
+ * @param {Color} [colorOuter=CLEAR_WHITE]
997
+ * @param {number} [angle]
998
+ * @param {boolean} [useWebGL=glEnable]
999
+ * @param {boolean} [screenSpace=drawScreenSpace]
1000
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1001
+ * @memberof Draw */
1002
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
1003
+ {
1004
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1005
+ ASSERT(isVector2(size), 'size must be a vec2');
1006
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
1007
+ ASSERT(isNumber(angle), 'angle must be a number');
1008
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
1009
+
1010
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1011
+
1012
+ if (useWebGL && glEnable)
1013
+ {
1014
+ ASSERT(!!glContext, 'WebGL is not enabled!');
1015
+ if (screenSpace)
1016
+ {
1017
+ if (glSkipScreenSpace) return;
1018
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
1019
+ }
1020
+ // fan as tristrip; rotate the boundary vertex by one slice per call
1021
+ // so back-to-back gradients at the same position have their hole
1022
+ // (from gpu edge-rule on the boundary line-degen) at different rim
1023
+ // verts and don't visibly stack
1024
+ const sides = glCircleSides;
1025
+ const radiusX = size.x/2, radiusY = size.y/2;
1026
+ const innerInt = colorInner.rgbaInt();
1027
+ const outerInt = colorOuter.rgbaInt();
1028
+ const offset = drawEllipseGradientOffset++;
1029
+ const c = cos(-angle), s = sin(-angle);
1030
+ const rim = (a) =>
1031
+ {
1032
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
1033
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
1034
+ };
1035
+ const startA = (offset%sides)/sides*PI*2;
1036
+ const points = [rim(startA)];
1037
+ const colors = [outerInt];
1038
+ for (let i=sides; i--;)
1039
+ {
1040
+ const a = ((i+offset)%sides)/sides*PI*2;
1041
+ points.push(pos);
1042
+ colors.push(innerInt);
1043
+ points.push(rim(a));
1044
+ colors.push(outerInt);
1045
+ }
1046
+ glDrawColoredPoints(points, colors);
1047
+ }
1048
+ else
1049
+ {
1050
+ // normal canvas 2D rendering method (slower)
1051
+ ++drawCount;
1052
+ ++primitiveCount;
1053
+ drawCanvas2D(pos, size, angle, false, (context)=>
1054
+ {
1055
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
1056
+ gradient.addColorStop(0, colorInner.toString());
1057
+ gradient.addColorStop(1, colorOuter.toString());
1058
+ context.fillStyle = gradient;
1059
+ context.beginPath();
1060
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
1061
+ context.fill();
1062
+ }, screenSpace, context);
1063
+ }
1064
+ }
1065
+
1066
+ /** Draw a circle filled with a radial gradient from the center to the rim
1067
+ * - Best when batched with other untextured polys
1068
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
1069
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
1070
+ * @param {Vector2} pos
1071
+ * @param {number} [size=1] - Diameter
1072
+ * @param {Color} [colorInner=WHITE]
1073
+ * @param {Color} [colorOuter=CLEAR_WHITE]
1074
+ * @param {boolean} [useWebGL=glEnable]
1075
+ * @param {boolean} [screenSpace=drawScreenSpace]
1076
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1077
+ * @memberof Draw */
1078
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
1079
+ {
1080
+ ASSERT(isNumber(size), 'size must be a number');
1081
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
1082
+ }
1083
+
1084
+ /**
1085
+ * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
1086
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1087
+ * @memberof Draw
1088
+ */
1089
+
1090
+ /** Draw directly to a 2d canvas context in world space.
1091
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
1092
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
1093
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
1094
+ * @param {Vector2} pos
1095
+ * @param {Vector2} size
1096
+ * @param {number} [angle]
1097
+ * @param {boolean} [mirror]
1098
+ * @param {Canvas2DDrawFunction} [drawFunction] - Needed, marked optional only because the ones before it are
1099
+ * @param {boolean} [screenSpace=drawScreenSpace]
1100
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1101
+ * @memberof Draw */
1102
+ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=drawScreenSpace, context=drawContext)
1103
+ {
1104
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1105
+ ASSERT(isVector2(size), 'size must be a vec2');
1106
+ ASSERT(isNumber(angle), 'angle must be a number');
1107
+ ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
1108
+
1109
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1110
+
1111
+ if (!screenSpace)
1112
+ {
1113
+ pos = worldToScreen(pos);
1114
+ size = size.scale(cameraScale);
1115
+ angle -= cameraAngle;
1116
+ }
1117
+ context.save();
1118
+ context.translate(pos.x+.5, pos.y+.5);
1119
+ context.rotate(angle);
1120
+ context.scale(mirror ? -size.x : size.x, -size.y);
1121
+ drawFunction(context);
1122
+ context.restore();
1123
+ }
1124
+
1125
+ ///////////////////////////////////////////////////////////////////////////////
1126
+ // Text Drawing Functions
1127
+
1128
+ /** Draw text on main canvas in world space
1129
+ * Automatically splits new lines into rows
1130
+ * @param {string|number} text
1131
+ * @param {Vector2} pos
1132
+ * @param {number} [size]
1133
+ * @param {Color} [color=WHITE]
1134
+ * @param {number} [lineWidth]
1135
+ * @param {Color} [lineColor=BLACK]
1136
+ * @param {'left'|'center'|'right'} [textAlign='center']
1137
+ * @param {string} [font=fontDefault]
1138
+ * @param {string} [fontStyle]
1139
+ * @param {number} [maxWidth]
1140
+ * @param {number} [angle]
1141
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1142
+ * @memberof Draw */
1143
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1144
+ {
1145
+ // convert to screen space
1146
+ pos = worldToScreen(pos);
1147
+ size *= cameraScale;
1148
+ lineWidth *= cameraScale;
1149
+ if (maxWidth !== undefined)
1150
+ maxWidth *= cameraScale;
1151
+ angle -= cameraAngle;
1152
+
1153
+ drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1154
+ }
1155
+
1156
+ /** Draw text in screen space
1157
+ * Automatically splits new lines into rows
1158
+ * @param {string|number} text
1159
+ * @param {Vector2} pos
1160
+ * @param {number} size
1161
+ * @param {Color} [color=WHITE]
1162
+ * @param {number} [lineWidth]
1163
+ * @param {Color} [lineColor=BLACK]
1164
+ * @param {'left'|'center'|'right'} [textAlign]
1165
+ * @param {string} [font=fontDefault]
1166
+ * @param {string} [fontStyle]
1167
+ * @param {number} [maxWidth]
1168
+ * @param {number} [angle] - Clockwise, like the other screen space draws
1169
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
1170
+ * @memberof Draw */
1171
+ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1172
+ {
1173
+ ASSERT(isStringLike(text), 'text must be a string');
1174
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1175
+ ASSERT(isNumber(size), 'size must be a number');
1176
+ ASSERT(isColor(color), 'color must be a color');
1177
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
1178
+ ASSERT(isColor(lineColor), 'lineColor must be a color');
1179
+ ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
1180
+ ASSERT(isStringLike(font), 'font must be a string');
1181
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1182
+ ASSERT(isNumber(angle), 'angle must be a number');
1183
+
1184
+ if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1185
+
1186
+ const lines = (text+'').split('\n');
1187
+ // save before style mutations so caller's context state is preserved
1188
+ context.save();
1189
+ context.fillStyle = color.toString();
1190
+ context.strokeStyle = lineColor.toString();
1191
+ context.lineWidth = lineWidth;
1192
+ context.textAlign = textAlign;
1193
+ context.font = fontStyle + ' ' + size + 'px '+ font;
1194
+ context.textBaseline = 'middle';
1195
+ context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1196
+ context.rotate(angle);
1197
+ let yOffset = -(lines.length-1) * size/2; // center vertically
1198
+ lines.forEach(line=>
1199
+ {
1200
+ lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
1201
+ context.fillText(line, 0, yOffset, maxWidth);
1202
+ yOffset += size;
1203
+ });
1204
+ context.restore();
1205
+ }
1206
+
1207
+ ///////////////////////////////////////////////////////////////////////////////
1208
+ // Drawing utilities
1209
+
1210
+ /** Load a texture at a specific index after engineInit, the images passed to engineInit load this way
1211
+ * @param {number} textureIndex - Index to store the texture at, an unused one
1212
+ * @param {string} [src] - Image source path
1213
+ * @return {Promise<TextureInfo>} Resolves to the texture info once the image loads, or fails to with a warning
1214
+ * @memberof Draw */
1215
+ async function loadTexture(textureIndex, src)
1216
+ {
1217
+ ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
1218
+ // engineInit's empty placeholder in slot 0, when it was given no images, or a load that failed can be replaced
1219
+ const old = textureInfos[textureIndex];
1220
+ ASSERT(!old?.size.x, 'textureIndex is already loaded!');
1221
+ old?.destroyWebGLTexture();
1222
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
1223
+
1224
+ const image = new Image;
1225
+ if (src)
1226
+ {
1227
+ await new Promise(resolve =>
1228
+ {
1229
+ image.onload = resolve;
1230
+ image.onerror = ()=>
1231
+ {
1232
+ console.warn('failed to load image: ' + src); // in release too, like the WebGL warning
1233
+ resolve();
1234
+ };
1235
+ image.crossOrigin = 'anonymous';
1236
+ image.src = src;
1237
+ });
1238
+ }
1239
+
1240
+ return textureInfos[textureIndex] = new TextureInfo(image);
1241
+ }
1242
+
1243
+ /** Convert from screen to world space coordinates
1244
+ * @param {Vector2} screenPos
1245
+ * @return {Vector2}
1246
+ * @memberof Draw */
1247
+ function screenToWorld(screenPos)
1248
+ {
1249
+ ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1250
+
1251
+ let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
1252
+ let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
1253
+ if (cameraAngle)
1254
+ {
1255
+ // apply camera rotation
1256
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
1257
+ const xr = x * c - y * s, yr = x * s + y * c;
1258
+ x = xr; y = yr;
1259
+ }
1260
+ return new Vector2(x + cameraPos.x, y + cameraPos.y);
1261
+ }
1262
+
1263
+ /** Convert from world to screen space coordinates
1264
+ * @param {Vector2} worldPos
1265
+ * @return {Vector2}
1266
+ * @memberof Draw */
1267
+ function worldToScreen(worldPos)
1268
+ {
1269
+ ASSERT(isVector2(worldPos), 'worldPos must be a vec2');
1270
+
1271
+ let x = worldPos.x - cameraPos.x;
1272
+ let y = worldPos.y - cameraPos.y;
1273
+ if (cameraAngle)
1274
+ {
1275
+ // apply inverse camera rotation
1276
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1277
+ const xr = x * c - y * s, yr = x * s + y * c;
1278
+ x = xr; y = yr;
1279
+ }
1280
+ return new Vector2
1281
+ (
1282
+ x * cameraScale + mainCanvasSize.x/2 - .5,
1283
+ y * -cameraScale + mainCanvasSize.y/2 - .5
1284
+ );
1285
+ }
1286
+
1287
+ /** Convert from screen to world space coordinates for a directional vector (no translation)
1288
+ * @param {Vector2} screenDelta
1289
+ * @return {Vector2}
1290
+ * @memberof Draw */
1291
+ function screenToWorldDelta(screenDelta)
1292
+ {
1293
+ ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2');
1294
+
1295
+ let x = screenDelta.x / cameraScale;
1296
+ let y = screenDelta.y / -cameraScale;
1297
+ if (cameraAngle)
1298
+ {
1299
+ // apply camera rotation
1300
+ const c = cos(-cameraAngle), s = sin(-cameraAngle);
1301
+ const xr = x * c - y * s, yr = x * s + y * c;
1302
+ x = xr; y = yr;
1303
+ }
1304
+ return new Vector2(x, y);
1305
+ }
1306
+
1307
+ /** Convert from world to screen space coordinates for a directional vector (no translation)
1308
+ * @param {Vector2} worldDelta
1309
+ * @return {Vector2}
1310
+ * @memberof Draw */
1311
+ function worldToScreenDelta(worldDelta)
1312
+ {
1313
+ ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2');
1314
+
1315
+ let x = worldDelta.x;
1316
+ let y = worldDelta.y;
1317
+ if (cameraAngle)
1318
+ {
1319
+ // apply inverse camera rotation
1320
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1321
+ const xr = x * c - y * s, yr = x * s + y * c;
1322
+ x = xr; y = yr;
1323
+ }
1324
+ return new Vector2(x * cameraScale, y * -cameraScale);
1325
+ }
1326
+
1327
+ /** Convert screen space transform to world space
1328
+ * @param {Vector2} screenPos
1329
+ * @param {Vector2} screenSize
1330
+ * @param {number} [screenAngle]
1331
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
1332
+ * @memberof Draw */
1333
+ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
1334
+ {
1335
+ ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
1336
+ ASSERT(isVector2(screenSize), 'screenSize must be a vec2');
1337
+ ASSERT(isNumber(screenAngle), 'screenAngle must be a number');
1338
+
1339
+ return [
1340
+ screenToWorld(screenPos),
1341
+ screenSize.scale(1/cameraScale),
1342
+ screenAngle + cameraAngle
1343
+ ];
1344
+ }
1345
+
1346
+ /** Get the size of the camera window in world space
1347
+ * @return {Vector2}
1348
+ * @memberof Draw */
1349
+ function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
1350
+
1351
+ /** Padding for each side of a rectangle, the sides left out are 0
1352
+ * @typedef {Object} CameraFitSides
1353
+ * @property {number} [top]
1354
+ * @property {number} [right]
1355
+ * @property {number} [bottom]
1356
+ * @property {number} [left]
1357
+ * @memberof Draw */
1358
+
1359
+ /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
1360
+ * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
1361
+ * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
1362
+ * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
1363
+ * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
1364
+ * or an object with any of {top, right, bottom, left}
1365
+ * @param {Vector2} center - Center of the rectangle in world space
1366
+ * @param {Vector2} size - Size of the rectangle in world space
1367
+ * @param {number|Vector2|CameraFitSides} [worldMargin] - World space padding added around the content rectangle
1368
+ * @param {number|Vector2|CameraFitSides} [screenInset] - Screen space padding in pixels reserved on each viewport edge
1369
+ * @return {number} - The new camera scale
1370
+ * @memberof Draw */
1371
+ function cameraFit(center, size, worldMargin, screenInset)
1372
+ {
1373
+ ASSERT(isVector2(center), 'center must be a vec2');
1374
+ ASSERT(isVector2(size), 'size must be a vec2');
1375
+
1376
+ // pad the content
1377
+ const margin = padSides(worldMargin);
1378
+ const inset = padSides(screenInset);
1379
+ const worldW = size.x + margin.left + margin.right;
1380
+ const worldH = size.y + margin.top + margin.bottom;
1381
+ const viewW = mainCanvasSize.x - inset.left - inset.right;
1382
+ const viewH = mainCanvasSize.y - inset.top - inset.bottom;
1383
+
1384
+ // bail on a degenerate rect or viewport rather than NaN the camera
1385
+ if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
1386
+ return cameraScale;
1387
+
1388
+ // scale to fit the padded content, measured along the camera's axes when it is turned
1389
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1390
+ const fitW = abs(c) * worldW + abs(s) * worldH, fitH = abs(s) * worldW + abs(c) * worldH;
1391
+ cameraScale = min(viewW / fitW, viewH / fitH);
1392
+
1393
+ // calculate offset vectors, the inset is on the screen, so it turns back into the world with the camera
1394
+ const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
1395
+ const ix = (inset.right - inset.left) * .5 / cameraScale, iy = (inset.top - inset.bottom) * .5 / cameraScale;
1396
+ const insetVector = vec2(ix * c + iy * s, iy * c - ix * s);
1397
+
1398
+ // apply the offsets and return camera scale
1399
+ cameraPos = center.add(marginVector).add(insetVector);
1400
+ return cameraScale;
1401
+
1402
+ function padSides(p)
1403
+ {
1404
+ // normalize a padding option to {top, right, bottom, left}
1405
+ if (p === undefined || isNumber(p))
1406
+ p = vec2(p);
1407
+ if (isVector2(p))
1408
+ return { top: p.y, right: p.x, bottom: p.y, left: p.x };
1409
+ return {
1410
+ top: p.top || 0,
1411
+ right: p.right || 0,
1412
+ bottom: p.bottom || 0,
1413
+ left: p.left || 0,
1414
+ };
1415
+ }
1416
+ }
1417
+
1418
+ /** Check if a box, point, or circle is on screen with a circle test
1419
+ * If size is a Vector2, uses the length as diameter
1420
+ * This can be used to cull offscreen objects from render or update
1421
+ * @param {Vector2} pos - world space position
1422
+ * @param {Vector2|number} [size] - world space size or diameter
1423
+ * @return {boolean}
1424
+ * @memberof Draw */
1425
+ function isOnScreen(pos, size=0)
1426
+ {
1427
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1428
+ ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
1429
+
1430
+ // cameraScale of 0 collapses world coords; nothing is visible
1431
+ if (!cameraScale) return false;
1432
+
1433
+ // circle on screen test, worldToScreen inlined in doubled screen units so it compares against the full canvas size
1434
+ let x = pos.x - cameraPos.x;
1435
+ let y = pos.y - cameraPos.y;
1436
+ if (cameraAngle)
1437
+ {
1438
+ // apply inverse camera rotation
1439
+ const c = cos(cameraAngle), s = sin(cameraAngle);
1440
+ const xr = x * c - y * s, yr = x * s + y * c;
1441
+ x = xr; y = yr;
1442
+ }
1443
+ x *= cameraScale*2; y *= -cameraScale*2;
1444
+
1445
+ if (size instanceof Vector2)
1446
+ size = size.length(); // use length of vector as diameter
1447
+ size *= cameraScale;
1448
+
1449
+ // check against screen bounds
1450
+ const w = mainCanvasSize.x, h = mainCanvasSize.y;
1451
+ return x + size > -w && x - size < w &&
1452
+ y + size > -h && y - size < h;
1453
+ }
1454
+
1455
+ /** Enable additive blending
1456
+ * @param {boolean} [additive]
1457
+ * @memberof Draw */
1458
+ function setAdditiveBlendMode(additive=true)
1459
+ {
1460
+ glAdditive = additive;
1461
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1462
+ }
1463
+
1464
+ /** Set the Shader that 2D draws use from now on, none for the engine's own
1465
+ * - The object render loop sets each object's own shader, so this is for draws in gameRender and gameRenderPost
1466
+ * @param {Shader} [shader]
1467
+ * @memberof Draw */
1468
+ function setShader(shader)
1469
+ {
1470
+ ASSERT(!shader || shader instanceof Shader, 'shader must be a Shader');
1471
+ glCustomShader = shader || undefined; // null is no shader too, so it batches with none
1472
+ }
1473
+
1474
+ /** Set an extra canvas to composite behind the engine canvases when combining
1475
+ * Plugins that insert their own canvas below the LittleJS canvases should set
1476
+ * this so it appears in screenshots and video capture
1477
+ * @param {HTMLCanvasElement} [canvas]
1478
+ * @memberof Draw */
1479
+ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
1480
+
1481
+ /** Combines LittleJS canvases onto the main canvas
1482
+ * This is necessary for things like screenshots and video
1483
+ * @memberof Draw */
1484
+ function combineCanvases()
1485
+ {
1486
+ // this composites raw canvases so it works in backing store pixels,
1487
+ // mainCanvasSize is css pixels and would throw away resolution
1488
+ const w = mainCanvas.width, h = mainCanvas.height;
1489
+ workCanvas.width = w;
1490
+ workCanvas.height = h;
1491
+ // remove background alpha — explicit fillStyle so a previous caller
1492
+ // leaving workContext.fillStyle transparent can't silently no-op this
1493
+ workContext.fillStyle = '#000';
1494
+ workContext.fillRect(0,0,w,h);
1495
+ if (backgroundCanvas)
1496
+ workContext.drawImage(backgroundCanvas, 0, 0, w, h);
1497
+ glCopyToContext(workContext);
1498
+ workContext.drawImage(mainCanvas, 0, 0);
1499
+
1500
+ // draw back 1:1, mainContext is scaled to css pixels
1501
+ mainContext.save();
1502
+ mainContext.setTransform(1, 0, 0, 1, 0, 0);
1503
+ mainContext.drawImage(workCanvas, 0, 0);
1504
+ mainContext.restore();
1505
+ }
1506
+
1507
+ // tint straight color pixels in place, rgb times color, plus additiveColor with alpha when it adds anything;
1508
+ // returns true when color.a went into the alpha, so it is not applied again as globalAlpha
1509
+ function tintImageData(data, color, additiveColor)
1510
+ {
1511
+ if (additiveColor && !isBlack(additiveColor))
1512
+ {
1513
+ // multiply + additive (slower), color.a is baked into the alpha channel here
1514
+ const colorMultiply = [color.r, color.g, color.b, color.a];
1515
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
1516
+ additiveColor.b * 255, additiveColor.a * 255];
1517
+ for (let i = 0; i < data.length; ++i)
1518
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
1519
+ return true;
1520
+ }
1521
+
1522
+ // RGB only, faster — alpha left intact for the caller
1523
+ for (let i = 0; i < data.length; i+=4)
1524
+ {
1525
+ data[i ] *= color.r;
1526
+ data[i+1] *= color.g;
1527
+ data[i+2] *= color.b;
1528
+ }
1529
+ return false;
1530
+ }
1531
+
1532
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
1533
+ // image's native resolution. Returns the work canvas, suitable for
1534
+ // passing to context.createPattern. Used by drawTextureWrapped's
1535
+ // Canvas2D path. Caller is responsible for short-circuiting when no
1536
+ // tint is needed (i.e. color is white and additiveColor is black/none).
1537
+ function bakeTintedImage(image, color, additiveColor)
1538
+ {
1539
+ const w = image.width|0, h = image.height|0;
1540
+ workReadCanvas.width = w;
1541
+ workReadCanvas.height = h;
1542
+ workReadContext.drawImage(image, 0, 0);
1543
+
1544
+ const imageData = workReadContext.getImageData(0, 0, w, h);
1545
+ tintImageData(imageData.data, color, additiveColor);
1546
+ workReadContext.putImageData(imageData, 0, 0);
1547
+ return workReadCanvas;
1548
+ }
1549
+
1550
+ /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1551
+ * This is slower than normal drawImage when color is applied
1552
+ * @ignore
1553
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
1554
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
1555
+ * @param {number} sx
1556
+ * @param {number} sy
1557
+ * @param {number} sWidth
1558
+ * @param {number} sHeight
1559
+ * @param {number} dx
1560
+ * @param {number} dy
1561
+ * @param {number} dWidth
1562
+ * @param {number} dHeight
1563
+ * @param {Color} color
1564
+ * @param {Color} [additiveColor]
1565
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1566
+ * @memberof Draw */
1567
+ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1568
+ {
1569
+ const sx2 = bleed;
1570
+ const sy2 = bleed;
1571
+ sWidth = max(1,sWidth|0);
1572
+ sHeight = max(1,sHeight|0);
1573
+ const sWidth2 = sWidth - 2*bleed;
1574
+ const sHeight2 = sHeight - 2*bleed;
1575
+ if (!canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor)))
1576
+ {
1577
+ // white tint and nothing added, the texels are drawn as they are
1578
+ context.globalAlpha = color.a;
1579
+ context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1580
+ context.globalAlpha = 1;
1581
+ }
1582
+ else
1583
+ {
1584
+ // copy to offscreen canvas
1585
+ workReadCanvas.width = sWidth;
1586
+ workReadCanvas.height = sHeight;
1587
+ workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
1588
+
1589
+ // tint image using offscreen work context
1590
+ const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
1591
+ const alphaBaked = tintImageData(imageData.data, color, additiveColor);
1592
+ workReadContext.putImageData(imageData, 0, 0);
1593
+ if (!alphaBaked) context.globalAlpha = color.a;
1594
+ context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
1595
+ if (!alphaBaked) context.globalAlpha = 1;
1596
+ }
1597
+ }
1598
+
1599
+
1600
+ /** Returns true if fullscreen mode is active
1601
+ * @return {boolean}
1602
+ * @memberof Draw */
1603
+ function isFullscreen() { return !!document.fullscreenElement; }
1604
+
1605
+ /** Toggle fullscreen mode
1606
+ * @memberof Draw */
1607
+ function toggleFullscreen()
1608
+ {
1609
+ const rootElement = mainCanvas.parentElement;
1610
+ if (isFullscreen())
1611
+ {
1612
+ if (document.exitFullscreen)
1613
+ document.exitFullscreen();
1614
+ }
1615
+ else if (rootElement.requestFullscreen)
1616
+ rootElement.requestFullscreen();
1617
+ }
1618
+
1619
+ /** Set the cursor style
1620
+ * @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc)
1621
+ * @memberof Draw */
1622
+ function setCursor(cursorStyle = 'auto')
1623
+ {
1624
+ const rootElement = mainCanvas.parentElement;
1625
+ rootElement.style.cursor = cursorStyle;
1626
+ }
1627
+
1628
+ ///////////////////////////////////////////////////////////////////////////////
1629
+
1630
+ /** Engine font image, 8x8 font provided by the engine
1631
+ * @type {ImageFont}
1632
+ * @memberof Draw */
1633
+ let engineImageFont;
1634
+
1635
+ /**
1636
+ * Image Font Object - Draw text by using tiles in an image
1637
+ * - 96 characters (from space to tilde) are stored in an image
1638
+ * - A 8x8 default engine font is supplied for general use
1639
+ * - This system is WebGL enabled for fast text rendering
1640
+ * - Fonts can also be colored and scaled along each axis
1641
+ *
1642
+ * @memberof Draw
1643
+ * @example
1644
+ * // use built in font
1645
+ * const font = engineImageFont;
1646
+ *
1647
+ * // draw text
1648
+ * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50), 16);
1649
+ */
1650
+ class ImageFont
1651
+ {
1652
+ /** Create an image font
1653
+ * @param {TileInfo} tileInfo - Tile info of first character in font
1654
+ */
1655
+ constructor(tileInfo)
1656
+ {
1657
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1658
+
1659
+ /** @property {TileInfo} - Tile info for the font */
1660
+ this.tileInfo = tileInfo; // kept, not copied, so a loadSprite tile filled in once it loads is seen
1661
+ }
1662
+
1663
+ /** Draw text in world space using the image font
1664
+ * - The text stays upright and ignores cameraAngle, each glyph is snapped to whole screen pixels to keep it crisp
1665
+ * @param {string|number} text
1666
+ * @param {Vector2} pos
1667
+ * @param {Vector2|number} [size]
1668
+ * @param {boolean} [center=true] - center each line on pos, and the lines of multi-line text around it
1669
+ * @param {Color} [color=WHITE]
1670
+ * @param {boolean} [useWebGL=glEnable]
1671
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1672
+ */
1673
+ drawText(text, pos, size=1, center, color, useWebGL, context)
1674
+ {
1675
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1676
+
1677
+ if (typeof size === 'number')
1678
+ {
1679
+ // if size is a number, make it a vector
1680
+ ASSERT(size > 0);
1681
+ size *= cameraScale;
1682
+ size = new Vector2(size, size);
1683
+ }
1684
+ else
1685
+ size = size.scale(cameraScale);
1686
+ // world text, only drawn through screen space, so it still casts in a pass that skips screen space draws
1687
+ const skip = glSkipScreenSpace;
1688
+ glSkipScreenSpace = false;
1689
+ this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
1690
+ glSkipScreenSpace = skip;
1691
+ }
1692
+
1693
+ /** Draw text in screen space using the image font
1694
+ * @param {string|number} text
1695
+ * @param {Vector2} pos
1696
+ * @param {Vector2|number} size
1697
+ * @param {boolean} [center] - center each line on pos, and the lines of multi-line text around it
1698
+ * @param {Color} [color=WHITE]
1699
+ * @param {boolean} [useWebGL=glEnable]
1700
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
1701
+ */
1702
+ drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
1703
+ {
1704
+ ASSERT(isStringLike(text), 'text must be a string');
1705
+ ASSERT(isVector2(pos), 'pos must be a vec2');
1706
+ ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
1707
+ ASSERT(typeof center === 'boolean', 'center must be a boolean, the color comes after it, unlike drawText');
1708
+ ASSERT(isColor(color), 'color must be a color');
1709
+
1710
+ // if size is a number, make it a vector
1711
+ const glyphSize = typeof size === 'number' ? new Vector2(size, size) : size;
1712
+
1713
+ // precache objects for drawing, a copy of the tile info each glyph moves, the font's own stays put
1714
+ const drawPos = new Vector2;
1715
+ const tileInfo = this.tileInfo.frame(0);
1716
+
1717
+ // draw each line of text, centered vertically like drawTextScreen when center is set
1718
+ const lines = (text+'').split('\n');
1719
+ const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1720
+ lines.forEach((line, j)=>
1721
+ {
1722
+ const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1723
+ for (let i=line.length; i--;)
1724
+ {
1725
+ // get the glyph, out of range characters use the last one
1726
+ const charCode = line.charCodeAt(i);
1727
+ this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1728
+
1729
+ // snap the glyph edges to whole pixels
1730
+ // tiles are drawn from their center, so snapping the center
1731
+ // to a whole pixel puts the edges on half pixels when the
1732
+ // size is even, and a row or column of the glyph then has
1733
+ // no pixel center inside it and is not rasterized at all
1734
+ // ceil picks the nearest aligned position, breaking ties
1735
+ // downward to match how this used to truncate
1736
+ drawPos.x = ceil(pos.x + i * glyphSize.x - centerOffset - glyphSize.x/2) + glyphSize.x/2 - .5;
1737
+ drawPos.y = ceil(pos.y + j * glyphSize.y - centerOffsetY - glyphSize.y/2) + glyphSize.y/2 - .5;
1738
+ drawTile(drawPos, glyphSize, tileInfo, color, 0, false, undefined, useWebGL, true, context);
1739
+ }
1740
+ });
1741
+ }
1742
+
1743
+ /** Get where a glyph sits in the texture: counted in the font's own columns when its tile has them, like a font
1744
+ * packed by loadSprite, otherwise along the texture's grid from the font's first tile, the way tile() lays it out
1745
+ * @param {number} index - Glyph number, 0 is the space and the characters follow in ASCII order
1746
+ * @param {Vector2} [pos] - Written into and returned, for a loop that places many
1747
+ * @return {Vector2} */
1748
+ getGlyphPos(index, pos=new Vector2)
1749
+ {
1750
+ const t = this.tileInfo, padding = t.padding;
1751
+ const w = t.size.x + padding*2, h = t.size.y + padding*2;
1752
+ if (t.columns)
1753
+ return pos.set(t.pos.x + index % t.columns * w, t.pos.y + (index / t.columns |0) * h);
1754
+ const columns = t.textureInfo.size.x / w |0;
1755
+ const glyph = ((t.pos.y - padding) / h |0) * columns + ((t.pos.x - padding) / w |0) + index;
1756
+ return pos.set(glyph % columns * w + padding, (glyph / columns |0) * h + padding);
1757
+ }
1758
+ }
1759
+
1760
+ // load engine font, called automatically on startup
1761
+ async function imageFontInit()
1762
+ {
1763
+ const image = new Image;
1764
+ await new Promise(resolve =>
1765
+ {
1766
+ image.onerror = image.onload = resolve;
1767
+ image.crossOrigin = 'anonymous';
1768
+ image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg==';
1769
+ });
1770
+
1771
+ const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
1772
+ const textureInfo = new TextureInfo(image);
1773
+ const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
1774
+ engineImageFont = new ImageFont(tileInfo);
1511
1775
  }