littlejsengine 1.19.3 → 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 (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  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 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  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 +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -1,742 +1,996 @@
1
- /**
2
- * LittleJS Tile Layer System
3
- * - Renders large tile-based levels efficiently using cached canvases
4
- * - Unlimited tile layers with automatic canvas allocation
5
- * - Layers support both rendering and collision detection
6
- * - Direct canvas2d drawing access for custom tile rendering
7
- * - TileLayer for rendering, TileCollisionLayer for physics
8
- * - Collision callbacks for tile interactions with objects
9
- * - Optimized raycast support for tile-based physics
10
- * - Integration with Box2D physics via Box2DTileLayer plugin
11
- * @namespace TileLayers
12
- */
13
-
14
- 'use strict';
15
-
16
- ///////////////////////////////////////////////////////////////////////////////
17
- // Tile Layer System
18
-
19
- /** Keep track of all tile layers with collision
20
- * @type {Array<TileCollisionLayer>}
21
- * @memberof TileLayers */
22
- const tileCollisionLayers = [];
23
-
24
- /** Get tile collision data for a given cell in the grid
25
- * @param {Vector2} pos
26
- * @param {boolean} [solidOnly] - Only check solid layers?
27
- * @return {number}
28
- * @memberof TileLayers */
29
- function tileCollisionGetData(pos, solidOnly=true)
30
- {
31
- // check all tile collision layers
32
- for (const layer of tileCollisionLayers)
33
- if (!solidOnly || layer.isSolid)
34
- {
35
- // convert world pos to layer local space
36
- const layerPos = pos.subtract(layer.pos);
37
- if (layerPos.arrayCheck(layer.size))
38
- {
39
- const data = layer.getCollisionData(layerPos);
40
- if (data) return data;
41
- }
42
- }
43
- return 0;
44
- }
45
-
46
- /** Check if a tile layer collides with another object
47
- * @param {Vector2} pos
48
- * @param {Vector2} [size=vec2()]
49
- * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
50
- * @param {boolean} [solidOnly] - Only check solid layers?
51
- * @return {TileCollisionLayer}
52
- * @memberof TileLayers */
53
- function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
54
- {
55
- for (const layer of tileCollisionLayers)
56
- {
57
- if (!solidOnly || layer.isSolid)
58
- if (layer.collisionTest(pos, size, callbackObject))
59
- return layer;
60
- }
61
- }
62
-
63
- /**
64
- * @callback TileCollisionCallback - Function to handle a tile collision test
65
- * @param {number} tileData - the value of the tile at the position
66
- * @param {Vector2} pos - world space position of tile where the collision occurred
67
- * @memberof TileLayers
68
- */
69
-
70
- /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
71
- * The point will be inside the colliding tile if it hits
72
- * @param {Vector2} posStart
73
- * @param {Vector2} posEnd
74
- * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
75
- * @param {Vector2} [normal] - Optional normal of the surface hit
76
- * @param {boolean} [solidOnly=true] - Only check solid layers?
77
- * @return {Vector2|undefined} - position of the center of the tile hit or undefined if no hit
78
- * @memberof TileLayers */
79
- function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
80
- {
81
- // check every layer and keep the closest hit so a far hit in an
82
- // earlier-registered layer doesn't shadow a closer hit in a later one
83
- let closestHit, closestDistSq, closestNormal;
84
- const scratchNormal = normal && vec2();
85
- for (const layer of tileCollisionLayers)
86
- {
87
- if (!solidOnly || layer.isSolid)
88
- {
89
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
90
- if (hitPos)
91
- {
92
- const d = posStart.distanceSquared(hitPos);
93
- if (closestHit === undefined || d < closestDistSq)
94
- {
95
- closestHit = hitPos;
96
- closestDistSq = d;
97
- if (normal) closestNormal = scratchNormal.copy();
98
- }
99
- }
100
- }
101
- }
102
- if (closestHit && normal) normal.setFrom(closestNormal);
103
- return closestHit;
104
- }
105
-
106
- ///////////////////////////////////////////////////////////////////////////////
107
- /**
108
- * Load tile layers from exported data
109
- * @param {Object} tileMapData - Level data from exported data
110
- * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture)
111
- * @param {number} [renderOrder] - Render order of the top layer
112
- * @param {number} [collisionLayer] - Layer to use for collision if any
113
- * @param {boolean} [draw] - Should the layer be drawn automatically
114
- * @return {Array<TileCollisionLayer>}
115
- * @memberof TileLayers */
116
- function tileLayersLoad(tileMapData, tileInfo=tile(), renderOrder=0, collisionLayer, draw=true)
117
- {
118
- if (!tileMapData)
119
- {
120
- // default level data if loading failed
121
- const s = 50;
122
- tileMapData = {};
123
- tileMapData.height = tileMapData.width = s;
124
- tileMapData.layers = [{}];
125
- tileMapData.layers[0].data = new Array(s*s).fill(0);
126
- }
127
-
128
- // validate the tile map data
129
- ASSERT(tileMapData.width && tileMapData.height);
130
- ASSERT(tileMapData.layers && tileMapData.layers.length);
131
-
132
- // create tile layers and fill with data
133
- const tileLayers = [];
134
- const levelSize = vec2(tileMapData.width, tileMapData.height);
135
- const layerCount = tileMapData.layers.length;
136
- for (let layerIndex=layerCount; layerIndex--;)
137
- {
138
- const dataLayer = tileMapData.layers[layerIndex];
139
- ASSERT(dataLayer.data && dataLayer.data.length);
140
- ASSERT(levelSize.area() === dataLayer.data.length);
141
-
142
- const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex);
143
- const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder);
144
- tileLayers[layerIndex] = tileLayer;
145
-
146
- // apply layer color
147
- const layerColor = dataLayer.tintcolor ?
148
- new Color().setHex(dataLayer.tintcolor) :
149
- dataLayer.color || WHITE;
150
- ASSERT(isColor(layerColor), 'layer color is not a color');
151
-
152
- for (let x=levelSize.x; x--;)
153
- for (let y=levelSize.y; y--;)
154
- {
155
- const pos = vec2(x, levelSize.y-1-y);
156
- const data = dataLayer.data[x + y*levelSize.x];
157
- if (data)
158
- {
159
- const layerData = new TileLayerData(data-1, 0, false, layerColor);
160
- tileLayer.setData(pos, layerData);
161
-
162
- // set collision for top layer
163
- if (layerIndex === collisionLayer)
164
- tileLayer.setCollisionData(pos, 1);
165
- }
166
- }
167
- if (draw)
168
- tileLayer.redraw();
169
- }
170
- return tileLayers;
171
- }
172
-
173
- ///////////////////////////////////////////////////////////////////////////////
174
- /**
175
- * Tile layer data object stores info about how to draw a tile
176
- * @memberof TileLayers
177
- * @example
178
- * // create tile layer data with tile index 0 and random orientation and color
179
- * const tileIndex = 0;
180
- * const direction = randInt(4)
181
- * const mirror = randInt(2);
182
- * const color = randColor();
183
- * const data = new TileLayerData(tileIndex, direction, mirror, color);
184
- */
185
- class TileLayerData
186
- {
187
- /** Create a tile layer data object, one for each tile in a TileLayer
188
- * @param {number} [tile] - The tile to use, untextured if undefined
189
- * @param {number} [direction] - Integer direction of tile, in 90 degree increments
190
- * @param {boolean} [mirror] - If the tile should be mirrored along the x axis
191
- * @param {Color} [color] - Color of the tile */
192
- constructor(tile, direction=0, mirror=false, color=new Color)
193
- {
194
- /** @property {number} - The tile to use, untextured if undefined */
195
- this.tile = tile;
196
- /** @property {number} - Integer direction of tile, in 90 degree increments */
197
- this.direction = direction;
198
- /** @property {boolean} - If the tile should be mirrored along the x axis */
199
- this.mirror = mirror;
200
- /** @property {Color} - Color of the tile */
201
- this.color = color.copy();
202
- }
203
-
204
- /** Set this tile to clear, it will not be rendered */
205
- clear() { this.tile = this.direction = 0; this.mirror = false; this.color = new Color; }
206
- }
207
-
208
- ///////////////////////////////////////////////////////////////////////////////
209
- /**
210
- * Canvas Layer - cached off screen rendering system
211
- * - Contains an offscreen canvas that can be rendered to
212
- * - WebGL rendering is optional, call updateWebGL to enable/update
213
- * @extends EngineObject
214
- * @memberof TileLayers
215
- * @example
216
- * const canvasLayer = new CanvasLayer(vec2(), vec2(200,100));
217
- */
218
- class CanvasLayer extends EngineObject
219
- {
220
- /** Create a canvas layer object
221
- * @param {Vector2} [pos] - World space position of the layer
222
- * @param {Vector2} [size] - World space size of the layer
223
- * @param {number} [angle] - Angle the layer is rotated by
224
- * @param {number} [renderOrder] - Objects sorted by renderOrder
225
- * @param {Vector2} [canvasSize] - Default size of canvas, can be changed later
226
- * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
227
- */
228
- constructor(pos, size, angle=0, renderOrder=0, canvasSize=vec2(512), useWebGL=true)
229
- {
230
- ASSERT(isVector2(canvasSize), 'canvasSize must be a Vector2');
231
- super(pos, size, undefined, angle, WHITE, renderOrder);
232
-
233
- /** @property {OffscreenCanvasRenderingContext2D} - The 2D canvas context used by this layer */
234
- this.context = headlessMode ? undefined : createCanvasContext(canvasSize.x, canvasSize.y);
235
- /** @property {OffscreenCanvas} - The canvas used by this layer */
236
- this.canvas = this.context?.canvas;
237
- /** @property {TextureInfo} - Texture info to use for this object rendering */
238
- this.textureInfo = new TextureInfo(this.canvas, useWebGL);
239
-
240
- // disable physics by default
241
- this.mass = 0;
242
- }
243
-
244
- /** Destroy this canvas layer */
245
- destroy()
246
- {
247
- if (this.destroyed) return;
248
-
249
- this.textureInfo.destroyWebGLTexture();
250
- super.destroy();
251
- }
252
-
253
- // Render the layer, called automatically by the engine
254
- render()
255
- {
256
- this.draw(this.pos, this.size, this.color, this.angle, this.mirror, this.additiveColor);
257
- }
258
-
259
- /** Draw this canvas layer centered in world space, with color applied if using WebGL
260
- * @param {Vector2} pos - Center in world space
261
- * @param {Vector2} [size] - Size in world space
262
- * @param {Color} [color] - Color to modulate with
263
- * @param {number} [angle] - Angle to rotate by
264
- * @param {boolean} [mirror] - If true image is flipped along the Y axis
265
- * @param {Color} [additiveColor] - Additive color to be applied if any
266
- * @param {boolean} [screenSpace] - If true the pos and size are in screen space
267
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
268
- * @memberof Draw */
269
- draw(pos, size, color=WHITE, angle=0, mirror=false, additiveColor, screenSpace=false, context)
270
- {
271
- // draw the canvas layer as a single tile that uses the whole texture
272
- const tileInfo = new TileInfo().setFullImage(this.textureInfo);
273
- const useWebGL = this.hasWebGL();
274
- drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
275
- }
276
-
277
- /** Create WebGL texture if necessary and copy layer canvas to it */
278
- updateWebGL()
279
- { this.textureInfo.createWebGLTexture(); }
280
-
281
- /** Check if this layer is using WebGL
282
- * @return {boolean} */
283
- hasWebGL()
284
- { return glEnable && this.textureInfo.hasWebGL(); }
285
- }
286
-
287
- ///////////////////////////////////////////////////////////////////////////////
288
- /**
289
- * Tile Layer - cached rendering system for tile layers
290
- * - Each Tile layer is rendered to an off screen canvas
291
- * - To allow dynamic modifications, layers are rendered using canvas 2d
292
- * - Some devices like mobile phones are limited to 4k texture resolution
293
- * - For with 16x16 tiles this limits layers to 256x256 on mobile devices
294
- * - Tile layers are centered on their corner, so normal levels are at (0,0)
295
- * @extends CanvasLayer
296
- * @memberof TileLayers
297
- * @example
298
- * const tileLayer = new TileLayer(vec2(), vec2(200,100));
299
- */
300
- class TileLayer extends CanvasLayer
301
- {
302
- /** Create a tile layer object
303
- * @param {Vector2} pos - World space position
304
- * @param {Vector2} size - World space size
305
- * @param {TileInfo} [tileInfo] - Default tile info for layer (used for size and texture)
306
- * @param {number} [renderOrder] - Objects are sorted by renderOrder
307
- * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
308
- */
309
- constructor(pos, size, tileInfo=tile(), renderOrder=0, useWebGL=true)
310
- {
311
- const canvasSize = tileInfo ? size.multiply(tileInfo.size) : size;
312
- super(pos, size, 0, renderOrder, canvasSize, useWebGL);
313
-
314
- /** @property {TileInfo} - Default tile info for layer */
315
- this.tileInfo = undefined;
316
- /** @property {Array<TileLayerData>} - Array of tile data for the layer */
317
- this.data = [];
318
- /** @property {boolean} - Is this layer using a webgl texture? */
319
- this.isUsingWebGL = false;
320
-
321
- if (headlessMode)
322
- {
323
- // disable rendering in headless mode
324
- this.render = ()=> {};
325
- this.redraw = ()=> {};
326
- this.redrawStart = ()=> {};
327
- this.redrawEnd = ()=> {};
328
- this.drawTileData = ()=> {};
329
- this.redrawTileData = ()=> {};
330
- this.drawLayerTile = ()=> {};
331
- this.drawLayerRect = ()=> {};
332
- this.drawTile = ()=> {};
333
- this.drawRect = ()=> {};
334
- this.clearLayerRect = ()=> {};
335
- return;
336
- }
337
-
338
- if (tileInfo)
339
- {
340
- // set tile info
341
- this.tileInfo = tileInfo.frame(0);
342
- this.tileInfo.bleed = 0; // disable bleed for tile layers
343
- }
344
-
345
- // init tile data
346
- for (let j = this.size.area(); j--;)
347
- this.data.push(new TileLayerData);
348
- }
349
-
350
- /** Set data at a given position in the array
351
- * @param {Vector2} layerPos - Local position in array
352
- * @param {TileLayerData} data - Data to set
353
- * @param {boolean} [redraw] - Force the tile to redraw if true */
354
- setData(layerPos, data, redraw=false)
355
- {
356
- layerPos = layerPos.floor();
357
- ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
358
- ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
359
-
360
- if (!layerPos.arrayCheck(this.size)) return;
361
- this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
362
-
363
- if (!redraw) return;
364
- const isRedraw = drawContext === this.context;
365
- isRedraw ? this.drawTileData(layerPos) : this.redrawTileData(layerPos);
366
- }
367
-
368
- /** Clear data at a given position in the array
369
- * @param {Vector2} layerPos - Local position in array
370
- * @param {boolean} [redraw] - Force the tile to redraw if true */
371
- clearData(layerPos, redraw=false)
372
- { this.setData(layerPos, new TileLayerData, redraw) }
373
-
374
- /** Get data at a given position in the array
375
- * @param {Vector2} layerPos - Local position in array
376
- * @return {TileLayerData|undefined} */
377
- getData(layerPos)
378
- {
379
- ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
380
- return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
381
- }
382
-
383
- // Update the tile layer, refresh texture if needed
384
- update()
385
- {
386
- if (!glEnable && this.isUsingWebGL)
387
- {
388
- // redraw the layer if webgl was disabled or context lost
389
- this.isUsingWebGL = false;
390
- this.redraw();
391
- }
392
- }
393
-
394
- // Render the tile layer, called automatically by the engine
395
- render()
396
- {
397
- ASSERT(drawContext !== this.context, 'must call redrawEnd() after drawing tiles!');
398
-
399
- const size = this.drawSize || this.size;
400
- const pos = this.pos.add(size.scale(.5));
401
- this.draw(pos, size, this.color, this.angle, this.mirror, this.additiveColor);
402
- }
403
-
404
- /** Called after this layer is redrawn, does nothing by default */
405
- onRedraw() {}
406
-
407
- /** Draw all the tile data to an offscreen canvas
408
- * - This may be slow if not using webgl but only needs to be done once */
409
- redraw()
410
- {
411
- this.redrawStart(true);
412
- for (let x = this.size.x; x--;)
413
- for (let y = this.size.y; y--;)
414
- this.drawTileData(vec2(x,y), false);
415
- this.isUsingWebGL && glFlush();
416
- this.onRedraw();
417
- this.redrawEnd();
418
- }
419
-
420
- /** Call to start the redraw process
421
- * - This can be used to manually update parts of the level
422
- * @param {boolean} [clear] - Should it clear the canvas before drawing */
423
- redrawStart(clear=false)
424
- {
425
- if (!this.context) return;
426
- ASSERT(drawContext !== this.context);
427
-
428
- // save current render settings
429
- /** @type {[CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D, Vector2, Vector2, number, Color]} */
430
- this.savedRenderSettings = [drawContext, mainCanvasSize, cameraPos, cameraScale, canvasClearColor];
431
-
432
- // set the draw canvas and context to this layer
433
- // use camera settings to match this layer's canvas
434
- drawContext = this.context;
435
- const tileSize = this.tileInfo?.size ?? vec2(1);
436
- mainCanvasSize = this.size.multiply(tileSize);
437
- canvasClearColor = CLEAR_BLACK;
438
- cameraPos = this.size.multiply(tileSize).scale(.5);
439
- cameraScale = 1;
440
-
441
- // set render target to this layer
442
- this.isUsingWebGL = this.hasWebGL();
443
- if (this.isUsingWebGL)
444
- glSetRenderTarget(this.textureInfo.glTexture, clear);
445
- else
446
- {
447
- // disable smoothing for pixel art
448
- this.context.imageSmoothingEnabled = !tilesPixelated;
449
- if (clear)
450
- {
451
- // clear and set size
452
- this.canvas.width = mainCanvasSize.x;
453
- this.canvas.height = mainCanvasSize.y;
454
- }
455
- }
456
- }
457
-
458
- /** Call to end the redraw process */
459
- redrawEnd()
460
- {
461
- if (!this.context) return;
462
- ASSERT(drawContext === this.context);
463
-
464
- // set stuff back to normal
465
- if (this.isUsingWebGL)
466
- glSetRenderTarget();
467
- [drawContext, mainCanvasSize, cameraPos, cameraScale, canvasClearColor] = this.savedRenderSettings;
468
- }
469
-
470
- /** Draw the tile at a given position in the tile layer
471
- * This can be used to clear out tiles when they are destroyed
472
- * Tiles can also be redrawn if inside a redrawStart/End block
473
- * @param {Vector2} layerPos
474
- * @param {boolean} [clear] - should the old tile be cleared out
475
- */
476
- drawTileData(layerPos, clear=true)
477
- {
478
- if (!this.context) return;
479
- ASSERT(drawContext === this.context, 'must call redrawStart() before drawing tiles');
480
-
481
- // clear out where the tile was, can be skipped for fully opaque tiles
482
- const drawSize = this.tileInfo?.size ?? vec2(1);
483
- const drawPos = layerPos.multiply(drawSize);
484
- clear && this.clearLayerRect(drawPos, drawSize);
485
-
486
- // draw the tile if it has layer data
487
- const d = this.getData(layerPos);
488
- if (!d || !d.tile) return;
489
-
490
- const tileInfo = this.tileInfo && this.tileInfo.index(d.tile);
491
- this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
492
- }
493
-
494
- /** Draw the tile at a given position in the tile layer
495
- * This can be used to clear tiles when they are destroyed
496
- * For better performance use drawTileData inside a redrawStart/End block
497
- * @param {Vector2} layerPos
498
- * @param {boolean} [clear] - should the old tile be cleared
499
- */
500
- redrawTileData(layerPos, clear=true)
501
- {
502
- if (!this.context) return;
503
- ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
504
-
505
- this.redrawStart();
506
- this.drawTileData(layerPos, clear);
507
- this.redrawEnd();
508
- }
509
-
510
- /** Draw textured tile in layer space
511
- * @param {Vector2} pos - Position in pixel coordinates
512
- * @param {Vector2} [size=vec2(1)] - Size of the tile
513
- * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
514
- * @param {Color} [color=WHITE] - Color to modulate with
515
- * @param {number} [angle] - Angle to rotate by
516
- * @param {boolean} [mirror] - Is image flipped along the Y axis?
517
- * @param {Color} [additiveColor] - Additive color to be applied if any */
518
- drawLayerTile(pos, size=vec2(1), tileInfo, color=WHITE,
519
- angle=0, mirror, additiveColor)
520
- {
521
- const drawPos = pos.add(size.scale(.5));
522
- drawTile(drawPos, size, tileInfo, color, angle, mirror, additiveColor, this.isUsingWebGL);
523
- }
524
-
525
- /** Clear a rectangle in layer space
526
- * @param {Vector2} pos
527
- * @param {Vector2} size
528
- * @param {Color} [color=WHITE] - Color to modulate with
529
- * @param {number} [angle] - Angle to rotate by
530
- */
531
- drawLayerRect(pos, size, color, angle=0)
532
- { this.drawLayerTile(pos, size, undefined, color, angle); }
533
-
534
- /** Draw a tile onto the layer canvas in world space
535
- * @param {Vector2} pos
536
- * @param {Vector2} [size=vec2(1)]
537
- * @param {TileInfo} [tileInfo]
538
- * @param {Color} [color=WHITE]
539
- * @param {number} [angle]
540
- * @param {boolean} [mirror] */
541
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
542
- {
543
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
544
- size = size.multiply(this.tileInfo.size);
545
- pos.y = this.canvas.height - pos.y;
546
-
547
- // draw the tile onto the layer canvas
548
- const oldMainCanvasSize = mainCanvasSize;
549
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
550
- const useWebGL = this.hasWebGL();
551
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
552
- const drawContext = useWebGL ? undefined : this.context;
553
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
554
- useWebGL && glSetRenderTarget();
555
- mainCanvasSize = oldMainCanvasSize;
556
- }
557
-
558
- /** Draw a rectangle onto the layer canvas in world space
559
- * @param {Vector2} pos
560
- * @param {Vector2} [size=vec2(1)]
561
- * @param {Color} [color=WHITE]
562
- * @param {number} [angle] */
563
- drawRect(pos, size, color, angle)
564
- { this.drawTile(pos, size, undefined, color, angle); }
565
-
566
- /** Clear a rectangle in layer space
567
- * @param {Vector2} pos - position in pixel coordinates
568
- * @param {Vector2} size
569
- */
570
- clearLayerRect(pos, size)
571
- {
572
- ASSERT(drawContext === this.context, 'must call redrawStart() before clearing tiles');
573
-
574
- const x = pos.x, y = this.canvas.height - pos.y - size.y;
575
- const useWebGL = this.hasWebGL();
576
- if (useWebGL)
577
- glClearRect(x, y, size.x, size.y);
578
- else
579
- this.context.clearRect(x, y, size.x, size.y);
580
- }
581
- }
582
-
583
- ///////////////////////////////////////////////////////////////////////////////
584
- /**
585
- * Tile Collision Layer - a tile layer with collision
586
- * - adds collision data and functions to TileLayer
587
- * - there can be multiple tile collision layers
588
- * @extends TileLayer
589
- * @memberof TileLayers
590
- */
591
- class TileCollisionLayer extends TileLayer
592
- {
593
- /** Create a tile layer object
594
- * @param {Vector2} pos - World space position
595
- * @param {Vector2} size - World space size
596
- * @param {TileInfo} [tileInfo] - Tile info for layer
597
- * @param {number} [renderOrder] - Objects are sorted by renderOrder
598
- * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
599
- */
600
- constructor(pos, size, tileInfo=tile(), renderOrder=0, useWebGL=true)
601
- {
602
- super(pos, size.floor(), tileInfo, renderOrder, useWebGL);
603
-
604
- /** @property {Array<number>} - The tile collision grid */
605
- this.collisionData = [];
606
- this.initCollision(this.size);
607
-
608
- // keep track of all collision layers
609
- tileCollisionLayers.push(this);
610
-
611
- // tile collision layers are solid by default
612
- this.isSolid = true;
613
- }
614
-
615
- /** Destroy this tile layer */
616
- destroy()
617
- {
618
- if (this.destroyed) return;
619
-
620
- // remove from collision layers array and destroy
621
- const index = tileCollisionLayers.indexOf(this);
622
- ASSERT(index >= 0, 'tile collision layer not found in array');
623
- index >= 0 && tileCollisionLayers.splice(index, 1);
624
- super.destroy();
625
- }
626
-
627
- /** Clear and initialize tile collision to new size
628
- * @param {Vector2} size - width and height of tile collision 2d grid */
629
- initCollision(size)
630
- {
631
- ASSERT(isVector2(size), 'size must be a Vector2');
632
- this.size = size.floor();
633
- this.collisionData = [];
634
- this.collisionData.length = size.area();
635
- this.collisionData.fill(0);
636
- }
637
-
638
- /** Set tile collision data for a given cell in the layer
639
- * @param {Vector2} layerPos
640
- * @param {number} [data] */
641
- setCollisionData(layerPos, data=1)
642
- {
643
- ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
644
- const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
645
- layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
646
- }
647
-
648
- /** Clear tile collision data for a given cell in the layer
649
- * @param {Vector2} layerPos */
650
- clearCollisionData(layerPos)
651
- { this.setCollisionData(layerPos, 0); }
652
-
653
- /** Get tile collision data for a given cell in the layer
654
- * @param {Vector2} layerPos
655
- * @return {number} */
656
- getCollisionData(layerPos)
657
- {
658
- ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
659
- const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
660
- return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
661
- }
662
-
663
- /** Check if collision with another object should occur
664
- * @param {Vector2} pos
665
- * @param {Vector2} [size=vec2()]
666
- * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
667
- * @return {boolean} */
668
- collisionTest(pos, size=new Vector2, callbackObject)
669
- {
670
- ASSERT(isVector2(pos) && isVector2(size), 'pos and size must be Vector2s');
671
- ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject');
672
-
673
- // make function to check for collision
674
- const collisionTest = callbackObject ? typeof callbackObject === 'function' ?
675
- (tileData, pos)=> callbackObject(tileData, pos) :
676
- (tileData, pos)=> callbackObject.collideWithTile(tileData, pos) :
677
- ()=> true;
678
-
679
- // check any tiles in the area for collision
680
- const posX = pos.x - this.pos.x;
681
- const posY = pos.y - this.pos.y;
682
- // reject AABBs entirely past either edge; without this, the negative
683
- // side leaks into row/col 0 because minX/minY clamp to 0 and the
684
- // point-test floor below forces maxX/maxY up to 1
685
- if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
686
- if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
687
- const minX = max(posX - size.x/2|0, 0);
688
- const minY = max(posY - size.y/2|0, 0);
689
- // ensure at least one cell is visited even when size is 0 and pos
690
- // lands exactly on an integer boundary (documented point-test mode)
691
- const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
692
- const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
693
- const hitPos = new Vector2;
694
- for (let y = minY; y < maxY; ++y)
695
- for (let x = minX; x < maxX; ++x)
696
- {
697
- // check if the object should collide with this tile
698
- const tileData = this.collisionData[y*this.size.x+x];
699
- if (tileData && collisionTest(tileData, hitPos.set(x+this.pos.x, y+this.pos.y)))
700
- return true;
701
- }
702
- return false;
703
- }
704
-
705
- /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
706
- * The point will be inside the colliding tile if it hits (may have a tiny shift)
707
- * @param {Vector2} posStart
708
- * @param {Vector2} posEnd
709
- * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
710
- * @param {Vector2} [normal] - Optional normal of the surface hit
711
- * @return {Vector2|undefined} */
712
- collisionRaycast(posStart, posEnd, callbackObject, normal)
713
- {
714
- ASSERT(isVector2(posStart) && isVector2(posEnd), 'positions must be Vector2s');
715
- ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject');
716
-
717
- // make function to check for collision
718
- const collisionTest = callbackObject ? typeof callbackObject === 'function' ?
719
- (tileData, pos)=> callbackObject(tileData, pos) :
720
- (tileData, pos)=> callbackObject.collideWithTile(tileData, pos) :
721
- (tileData)=> tileData > 0;
722
- const testFunction = (pos)=>
723
- {
724
- const tileData = this.getCollisionData(localPos.set(pos.x - this.pos.x, pos.y - this.pos.y));
725
- return tileData && collisionTest(tileData, pos);
726
- }
727
-
728
- // use line test against tile collision
729
- const localPos = new Vector2;
730
- const hitPos = lineTest(posStart, posEnd, testFunction, normal);
731
- if (debugRaycast && hitPos)
732
- {
733
- const tilePos = hitPos.floor().add(vec2(.5));
734
- debugRect(tilePos, vec2(1), '#f008');
735
- debugLine(posStart, posEnd, '#00f', .02);
736
- debugLine(posStart, hitPos, '#f00', .02);
737
- debugPoint(hitPos, '#0f0');
738
- normal && debugLine(hitPos, hitPos.add(normal), '#ff0', .02);
739
- }
740
- return hitPos;
741
- }
1
+ /**
2
+ * LittleJS Tile Layer System
3
+ * - Renders large tile-based levels efficiently using cached canvases
4
+ * - Unlimited tile layers with automatic canvas allocation
5
+ * - Layers support both rendering and collision detection
6
+ * - Direct canvas2d drawing access for custom tile rendering
7
+ * - TileLayer for rendering, TileCollisionLayer for physics
8
+ * - Collision callbacks for tile interactions with objects
9
+ * - Optimized raycast support for tile-based physics
10
+ * - Integration with Box2D physics via Box2DTileLayer plugin
11
+ * @namespace TileLayers
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ ///////////////////////////////////////////////////////////////////////////////
17
+ // Tile Layer System
18
+
19
+ /** Keep track of all tile layers with collision
20
+ * @type {Array<TileCollisionLayer>}
21
+ * @memberof TileLayers */
22
+ const tileCollisionLayers = [];
23
+
24
+ // a tile collision layer's position is whole numbers, so its cells are the world grid the physics lands objects on
25
+ function tileCollisionAssertWhole(layer)
26
+ { ASSERT(layer.pos.x % 1 === 0 && layer.pos.y % 1 === 0, 'a tile collision layer must sit at a whole number position', layer.pos); }
27
+
28
+ // the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data
29
+ function tileCollisionTester(callbackObject)
30
+ {
31
+ ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject');
32
+ return !callbackObject ? (tileData)=> tileData > 0 :
33
+ typeof callbackObject === 'function' ? (tileData, pos)=> callbackObject(tileData, pos) :
34
+ (tileData, pos)=> callbackObject.collideWithTile(tileData, pos);
35
+ }
36
+
37
+ /** Get tile collision data for a given cell in the grid
38
+ * @param {Vector2} pos
39
+ * @param {boolean} [solidOnly] - Only check solid layers?
40
+ * @return {number}
41
+ * @memberof TileLayers */
42
+ function tileCollisionGetData(pos, solidOnly=true)
43
+ {
44
+ // check all tile collision layers, in scalars since particles ask this every frame
45
+ // solid (positive) data wins, a negative marker is returned only when no layer is solid there
46
+ let found = 0;
47
+ for (const layer of tileCollisionLayers)
48
+ if (!solidOnly || layer.isSolid)
49
+ {
50
+ // world pos to the layer's cell
51
+ const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size;
52
+ if (x >= 0 && y >= 0 && x < size.x && y < size.y)
53
+ {
54
+ const data = layer.collisionData[(y|0)*size.x + (x|0)];
55
+ if (data > 0) return data;
56
+ if (data && !found) found = data;
57
+ }
58
+ }
59
+ return found;
60
+ }
61
+
62
+ /** Check if a tile layer collides with another object
63
+ * @param {Vector2} pos
64
+ * @param {Vector2} [size=vec2()]
65
+ * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
66
+ * @param {boolean} [solidOnly] - Only check solid layers?
67
+ * @return {TileCollisionLayer|undefined}
68
+ * @memberof TileLayers */
69
+ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
70
+ {
71
+ for (const layer of tileCollisionLayers)
72
+ {
73
+ if (!solidOnly || layer.isSolid)
74
+ if (layer.collisionTest(pos, size, callbackObject))
75
+ return layer;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @callback TileCollisionCallback - Decides whether a tile counts as solid for a collision test or raycast
81
+ * @param {number} tileData - the value of the tile at the position
82
+ * @param {Vector2} pos - world space position of tile where the collision occurred
83
+ * @return {boolean} - true for a hit; a callback that returns nothing lets everything through
84
+ * @memberof TileLayers
85
+ */
86
+
87
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
88
+ * The point will be inside the colliding tile if it hits
89
+ * @param {Vector2} posStart
90
+ * @param {Vector2} posEnd
91
+ * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
92
+ * @param {Vector2} [normal] - Optional normal of the surface hit
93
+ * @param {boolean} [solidOnly=true] - Only check solid layers?
94
+ * @return {Vector2|undefined} - where the ray meets the first tile hit, nudged just inside it, or undefined if no hit
95
+ * @memberof TileLayers */
96
+ function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
97
+ {
98
+ // check every layer and keep the closest hit so a far hit in an
99
+ // earlier-registered layer doesn't shadow a closer hit in a later one
100
+ let closestHit, closestDistSq, closestNormal;
101
+ const scratchNormal = normal && vec2();
102
+ for (const layer of tileCollisionLayers)
103
+ {
104
+ if (!solidOnly || layer.isSolid)
105
+ {
106
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
107
+ if (hitPos)
108
+ {
109
+ const d = posStart.distanceSquared(hitPos);
110
+ if (closestHit === undefined || d < closestDistSq)
111
+ {
112
+ closestHit = hitPos;
113
+ closestDistSq = d;
114
+ if (normal) closestNormal = scratchNormal.copy();
115
+ }
116
+ }
117
+ }
118
+ }
119
+ if (closestHit && normal) normal.setFrom(closestNormal);
120
+ return closestHit;
121
+ }
122
+
123
+ ///////////////////////////////////////////////////////////////////////////////
124
+ // Tiled's flip flags, horizontal, vertical and diagonal as bits 2, 1 and 0, as [direction, mirror]
125
+ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [1,1]];
126
+
127
+ /**
128
+ * Load tile layers from exported data
129
+ * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
130
+ * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read
131
+ * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices
132
+ * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and
133
+ * visibility carry to the layers inside it
134
+ * - An object or image layer keeps its index, with its slot in the returned array left empty
135
+ * - A hidden layer (visible false) is loaded, its collision included, but not drawn; its render
136
+ * is a no-op, delete that and call redraw() to show it
137
+ * @param {Object} tileMapData - Level data from exported data
138
+ * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture), tile() by default, none when no image is loaded
139
+ * @param {number} [renderOrder] - Render order of the top layer
140
+ * @param {number} [collisionLayer] - Layer to use for collision if any
141
+ * @param {boolean} [draw] - Should the layer be drawn automatically
142
+ * @return {Array<TileCollisionLayer>}
143
+ * @memberof TileLayers */
144
+ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrder=0, collisionLayer, draw=true)
145
+ {
146
+ if (!tileMapData)
147
+ {
148
+ // default level data if loading failed
149
+ const s = 50;
150
+ tileMapData = {};
151
+ tileMapData.height = tileMapData.width = s;
152
+ tileMapData.layers = [{}];
153
+ tileMapData.layers[0].data = new Array(s*s).fill(0);
154
+ }
155
+
156
+ // the editor, in debug builds, keeps the map as the source of its edits and brings back autosaved ones
157
+ tileMapData = editorMapRestore(tileMapData);
158
+
159
+ // validate the tile map data
160
+ ASSERT(tileMapData.width && tileMapData.height);
161
+ ASSERT(tileMapData.layers && tileMapData.layers.length);
162
+
163
+ // flatten group layers in order, a group's color and visibility carry to the layers inside it
164
+ /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */
165
+ const layers = [];
166
+ const addLayers = (dataLayers, groupColor, groupVisible)=>
167
+ {
168
+ for (const dataLayer of dataLayers)
169
+ {
170
+ const type = dataLayer.type;
171
+ if (type && type !== 'tilelayer' && type !== 'group')
172
+ {
173
+ layers.push({dataLayer}); // an object or image layer has no tiles, its slot is left empty
174
+ continue;
175
+ }
176
+
177
+ // apply layer color, Tiled writes a tint with alpha as #AARRGGBB
178
+ const tint = dataLayer.tintcolor;
179
+ const color = tint ? tileLayersColor(tint) : (dataLayer.color || WHITE).copy();
180
+ ASSERT(isColor(color), 'layer color is not a color');
181
+ color.a *= dataLayer.opacity ?? 1;
182
+ const visible = groupVisible && dataLayer.visible !== false;
183
+ if (type === 'group')
184
+ addLayers(dataLayer.layers || [], groupColor.multiply(color), visible);
185
+ else
186
+ layers.push({dataLayer, color: groupColor.multiply(color), visible});
187
+ }
188
+ };
189
+ addLayers(tileMapData.layers, WHITE, true);
190
+
191
+ // create tile layers and fill with data
192
+ const tileLayers = [];
193
+ const levelSize = vec2(tileMapData.width, tileMapData.height);
194
+ const layerCount = layers.length;
195
+ for (let layerIndex=layerCount; layerIndex--;)
196
+ {
197
+ const {dataLayer, color: layerColor, visible} = layers[layerIndex];
198
+ if (!layerColor)
199
+ continue;
200
+ ASSERT(dataLayer.data && dataLayer.data.length, 'tile layer has no data, infinite maps and compressed layers are not supported');
201
+ ASSERT(levelSize.area() === dataLayer.data.length);
202
+
203
+ const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex);
204
+ const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder);
205
+ tileLayer.isSolid = layerIndex === collisionLayer; // the others are art, the solid tests skip them
206
+ tileLayers[layerIndex] = tileLayer;
207
+ if (!visible)
208
+ tileLayer.render = ()=> {}; // a hidden layer keeps its tiles and collision but is not drawn
209
+
210
+ for (let x=levelSize.x; x--;)
211
+ for (let y=levelSize.y; y--;)
212
+ {
213
+ const pos = vec2(x, levelSize.y-1-y);
214
+ const data = dataLayer.data[x + y*levelSize.x];
215
+ if (data)
216
+ {
217
+ // Tiled keeps a tile's flips in its top bits, horizontal, vertical and diagonal, the diagonal
218
+ // applied first; each of the 8 is a quarter turn direction with or without a mirror
219
+ const [direction, mirror] = tileLayersTiledFlips[data >>> 29];
220
+ const tileIndex = (data & 0x0fffffff) - 1; // bit 28, a hexagonal turn, is not read
221
+ const layerData = new TileLayerData(tileIndex, direction, !!mirror, layerColor);
222
+ tileLayer.setData(pos, layerData);
223
+
224
+ // set collision for top layer
225
+ if (layerIndex === collisionLayer)
226
+ tileLayer.setCollisionData(pos, 1);
227
+ }
228
+ }
229
+ if (draw && visible)
230
+ tileLayer.redraw();
231
+ }
232
+ editorMapLoaded(tileMapData, tileLayers, layers);
233
+ return tileLayers;
234
+ }
235
+
236
+ // a color as Tiled writes it, #AARRGGBB, or #RRGGBB
237
+ function tileLayersColor(hex)
238
+ { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); }
239
+
240
+ ///////////////////////////////////////////////////////////////////////////////
241
+ // Object layers
242
+
243
+ // the types a map's objects are made from, by the name they have in Tiled
244
+ const objectLayersTypes = new Map;
245
+
246
+ /** Add a type of object, so objectLayersLoad makes one wherever a map's object layer has an object of that type
247
+ * - The name is the object's type in Tiled (its class in Tiled 1.9); it is a string because minified builds
248
+ * rename classes
249
+ * - A class, or any function with a prototype, is made with new make(pos); an arrow function is called as
250
+ * make(pos), for what is not an object, like a player start
251
+ * - The defaults, and then the properties the object has in Tiled, are set on what it made
252
+ * - Adding a name again replaces it
253
+ * @param {string} name - The type the objects have in Tiled
254
+ * @param {Function} make - A class made at each object's position, or a function called with it
255
+ * @param {Object} [defaults] - Properties set on each one made, the level editor shows inputs for them
256
+ * @param {TileInfo} [tileInfo] - An icon for the level editor
257
+ * @memberof TileLayers
258
+ * @example
259
+ * objectLayersAddType('Coin', Coin, {value: 1}, tile(5, 16));
260
+ * objectLayersAddType('PlayerStart', (pos)=> playerStartPos = pos); */
261
+ function objectLayersAddType(name, make, defaults={}, tileInfo)
262
+ {
263
+ ASSERT(isStringLike(name), 'object type name must be a string');
264
+ ASSERT(typeof make === 'function', 'make must be a class or function');
265
+ ASSERT(!!defaults && typeof defaults === 'object', 'defaults must be an object');
266
+ objectLayersTypes.set(String(name), {make, defaults, tileInfo});
267
+ }
268
+
269
+ /** Make the objects in a map's object layers, each from the type added for its name with objectLayersAddType
270
+ * - An object is made at its position, the world y up as tileLayersLoad places the layers; layer offsets are
271
+ * not read, and a shape or tile object is made at its position too
272
+ * - Group layers are flattened in order, as tileLayersLoad does
273
+ * - The object's properties in Tiled are set over the type's defaults: numbers, booleans, strings, and colors,
274
+ * and for a Vector2 default the string x,y
275
+ * - An object whose type was not added is skipped, with a warning in debug builds
276
+ * @param {Object} tileMapData - The same Tiled map given to tileLayersLoad
277
+ * @return {Array<any>} - What each object's type made, a function that made nothing is left out
278
+ * @memberof TileLayers */
279
+ function objectLayersLoad(tileMapData)
280
+ {
281
+ // the level editor, in debug builds, keeps the map and brings back its autosaved objects before they are made
282
+ tileMapData && editorMapRestore(tileMapData);
283
+ const made = [];
284
+ const addObjects = (dataLayers)=>
285
+ {
286
+ for (const dataLayer of dataLayers || [])
287
+ {
288
+ if (dataLayer.type === 'group')
289
+ addObjects(dataLayer.layers);
290
+ if (dataLayer.type !== 'objectgroup')
291
+ continue;
292
+ for (const object of dataLayer.objects || [])
293
+ {
294
+ const result = objectLayersMake(tileMapData, object);
295
+ editorObjectMade(tileMapData, dataLayer, object, result); // debug builds link it for the level editor
296
+ result && made.push(result);
297
+ }
298
+ }
299
+ };
300
+ addObjects(tileMapData?.layers);
301
+ return made;
302
+ }
303
+
304
+ // the defaults of an object's type, a Color or Vector2 copied for each, then its properties in Tiled over them
305
+ function objectLayersProperties(type, object)
306
+ {
307
+ const properties = {};
308
+ for (const [key, value] of Object.entries(type.defaults))
309
+ properties[key] = value?.copy ? value.copy() : value;
310
+ for (const property of object.properties || [])
311
+ {
312
+ const {name, value} = property;
313
+ if (property.type === 'color')
314
+ value && (properties[name] = tileLayersColor(value)); // an empty color is unset
315
+ else if (isVector2(type.defaults[name]))
316
+ {
317
+ // Tiled has no Vector2, one is the string x,y, and one it can not read keeps the default
318
+ const parts = String(value).split(','), [x, y] = parts.map(Number);
319
+ parts.length === 2 && parts.every((part)=> part.trim()) && isNumber(x) && isNumber(y) &&
320
+ (properties[name] = vec2(x, y));
321
+ }
322
+ else
323
+ properties[name] = value;
324
+ }
325
+ return properties;
326
+ }
327
+
328
+ // make one Tiled object from the type added for its name, undefined when there is no such type or it made nothing
329
+ function objectLayersMake(tileMapData, object)
330
+ {
331
+ const name = object.type || object.class, type = objectLayersTypes.get(name);
332
+ if (!type)
333
+ {
334
+ debug && console.warn(`objectLayersLoad: no type added for ${name}, skipped`);
335
+ return;
336
+ }
337
+ const {height=0, tilewidth=1, tileheight=1} = tileMapData;
338
+ const pos = vec2(object.x / tilewidth, height - object.y / tileheight);
339
+ const {make} = type, result = make.prototype ? new make(pos) : make(pos);
340
+ if (!result || typeof result !== 'object') return;
341
+ return Object.assign(result, objectLayersProperties(type, object));
342
+ }
343
+
344
+ ///////////////////////////////////////////////////////////////////////////////
345
+ /**
346
+ * Tile layer data object stores info about how to draw a tile
347
+ * @memberof TileLayers
348
+ * @example
349
+ * // create tile layer data with tile index 0 and random orientation and color
350
+ * const tileIndex = 0;
351
+ * const direction = randInt(4)
352
+ * const mirror = randBool();
353
+ * const color = randColor();
354
+ * const data = new TileLayerData(tileIndex, direction, mirror, color);
355
+ */
356
+ class TileLayerData
357
+ {
358
+ /** Create a tile layer data object, one for each tile in a TileLayer
359
+ * @param {number} [tile] - The tile to use, from 0 like tile(); undefined is an empty cell that draws nothing
360
+ * @param {number} [direction] - Integer direction of tile, in 90 degree increments
361
+ * @param {boolean} [mirror] - If the tile should be mirrored along the x axis
362
+ * @param {Color} [color] - Color of the tile */
363
+ constructor(tile, direction=0, mirror=false, color=new Color)
364
+ {
365
+ /** @property {number|undefined} - The tile to use, from 0 like tile(); undefined is an empty cell that draws nothing
366
+ * @type {number|undefined} */
367
+ this.tile = tile;
368
+ /** @property {number} - Integer direction of tile, in 90 degree increments */
369
+ this.direction = direction;
370
+ /** @property {boolean} - If the tile should be mirrored along the x axis */
371
+ this.mirror = mirror;
372
+ /** @property {Color} - Color of the tile */
373
+ this.color = color.copy();
374
+ }
375
+
376
+ /** Set this tile to clear, it will not be rendered */
377
+ clear() { this.tile = undefined; this.direction = 0; this.mirror = false; this.color = new Color; }
378
+ }
379
+
380
+ ///////////////////////////////////////////////////////////////////////////////
381
+ /**
382
+ * Canvas Layer - cached off screen rendering system
383
+ * - Contains an offscreen canvas that can be rendered to
384
+ * - WebGL rendering is optional, call updateWebGL to enable/update
385
+ * - A TileLayer using WebGL redraws into its texture and leaves this canvas blank, so drawing on its context
386
+ * only shows on a layer made with useWebGL=false (or with WebGL off); use drawLayerTile/drawLayerRect inside
387
+ * redrawStart/End for drawing that works both ways
388
+ * @extends EngineObject
389
+ * @memberof TileLayers
390
+ * @example
391
+ * const canvasLayer = new CanvasLayer(vec2(), vec2(200,100));
392
+ */
393
+ class CanvasLayer extends EngineObject
394
+ {
395
+ /** Create a canvas layer object
396
+ * @param {Vector2} [pos] - World space position of the layer
397
+ * @param {Vector2} [size] - World space size of the layer
398
+ * @param {number} [angle] - Angle the layer is rotated by
399
+ * @param {number} [renderOrder] - Objects sorted by renderOrder
400
+ * @param {Vector2} [canvasSize] - Default size of canvas, can be changed later
401
+ * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
402
+ */
403
+ constructor(pos, size, angle=0, renderOrder=0, canvasSize=vec2(512), useWebGL=true)
404
+ {
405
+ ASSERT(isVector2(canvasSize), 'canvasSize must be a Vector2');
406
+ super(pos, size, undefined, angle, WHITE, renderOrder);
407
+
408
+ /** @property {OffscreenCanvasRenderingContext2D} - The 2D canvas context used by this layer */
409
+ this.context = headlessMode ? undefined : createCanvasContext(canvasSize.x, canvasSize.y);
410
+ /** @property {OffscreenCanvas} - The canvas used by this layer */
411
+ this.canvas = this.context?.canvas;
412
+ /** @property {TextureInfo} - Texture info to use for this object rendering */
413
+ this.textureInfo = new TextureInfo(this.canvas, useWebGL);
414
+
415
+ // a texture past the device's limit fails with only a WebGL warning and draws black, and phones often
416
+ // allow 4096 where a desktop allows 16384, so say so in release builds too
417
+ const maxSize = useWebGL && glContext ? glContext.getParameter(glContext.MAX_TEXTURE_SIZE) : 0;
418
+ if (maxSize && max(canvasSize.x, canvasSize.y) > maxSize)
419
+ console.warn(`LittleJS: a ${canvasSize.x}x${canvasSize.y} layer is over this device's ${maxSize} pixel texture limit and draws black, split it into smaller layers`);
420
+
421
+ // disable physics by default
422
+ this.mass = 0;
423
+ }
424
+
425
+ /** Destroy this canvas layer
426
+ * @param {boolean} [immediate] - Remove it now, as EngineObject.destroy does, children included */
427
+ destroy(immediate=false)
428
+ {
429
+ if (this.destroyed) return;
430
+
431
+ this.textureInfo.destroyWebGLTexture();
432
+ super.destroy(immediate);
433
+ }
434
+
435
+ // Render the layer, called automatically by the engine
436
+ render()
437
+ {
438
+ this.draw(this.pos, this.size, this.color, this.angle, this.mirror, this.additiveColor, false);
439
+ }
440
+
441
+ /** Draw this canvas layer centered in world space
442
+ * @param {Vector2} pos - Center in world space
443
+ * @param {Vector2} [size] - Size in world space
444
+ * @param {Color} [color] - Color to modulate with
445
+ * @param {number} [angle] - Angle to rotate by
446
+ * @param {boolean} [mirror] - If true image is flipped along the Y axis
447
+ * @param {Color} [additiveColor] - Additive color to be applied if any
448
+ * @param {boolean} [screenSpace=drawScreenSpace] - If true the pos and size are in screen space
449
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to */
450
+ draw(pos, size, color=WHITE, angle=0, mirror=false, additiveColor, screenSpace=drawScreenSpace, context)
451
+ {
452
+ // the canvas may have been resized since, updateWebGL only refreshes the size for WebGL
453
+ const t = this.textureInfo, c = this.canvas;
454
+ if (c && !this.hasWebGL() && (c.width !== t.size.x || c.height !== t.size.y))
455
+ {
456
+ t.size = vec2(c.width, c.height);
457
+ t.sizeInverse = vec2(1/c.width, 1/c.height);
458
+ }
459
+
460
+ // draw the canvas layer as a single tile that uses the whole texture
461
+ const tileInfo = new TileInfo().setFullImage(t);
462
+ const useWebGL = !context && this.hasWebGL(); // a context given is drawn to with Canvas2D
463
+ drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
464
+ }
465
+
466
+ /** Create WebGL texture if necessary and copy layer canvas to it */
467
+ updateWebGL()
468
+ { this.textureInfo.createWebGLTexture(); }
469
+
470
+ /** Check if this layer is using WebGL
471
+ * @return {boolean} */
472
+ hasWebGL()
473
+ { return glEnable && this.textureInfo.hasWebGL(); }
474
+ }
475
+
476
+ ///////////////////////////////////////////////////////////////////////////////
477
+ // a layer's default tile, none when no image is loaded, so a collision only layer works in a game with no images
478
+ function tileLayerDefaultTile() { return textureInfos[0]?.size.x ? tile() : undefined; }
479
+
480
+ /**
481
+ * Tile Layer - cached rendering system for tile layers
482
+ * - Tiles are drawn once into a texture, a WebGL render target, or the layer's canvas when WebGL is off
483
+ * or useWebGL is false, and the layer draws that as one image
484
+ * - Some devices like mobile phones are limited to 4k textures, which with 16x16 tiles limits a layer to 256x256
485
+ * - Tile layers are centered on their corner, so normal levels are at (0,0)
486
+ * @extends CanvasLayer
487
+ * @memberof TileLayers
488
+ * @example
489
+ * const tileLayer = new TileLayer(vec2(), vec2(200,100));
490
+ */
491
+ class TileLayer extends CanvasLayer
492
+ {
493
+ /** Create a tile layer object
494
+ * @param {Vector2} pos - World space position
495
+ * @param {Vector2} size - World space size
496
+ * @param {TileInfo} [tileInfo] - Default tile info for layer (used for size and texture), tile() by default, none when no image is loaded
497
+ * @param {number} [renderOrder] - Objects are sorted by renderOrder
498
+ * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
499
+ */
500
+ constructor(pos, size, tileInfo=tileLayerDefaultTile(), renderOrder=0, useWebGL=true)
501
+ {
502
+ ASSERT(!tileInfo || tileInfo.size.x > 0 && tileInfo.size.y > 0,
503
+ 'the tile has no size yet, a loadSprite tile is filled in once spritesReady resolves');
504
+ size = size.floor(); // whole cells, a fractional size would never finish filling the data
505
+ const canvasSize = tileInfo ? size.multiply(tileInfo.size) : size;
506
+ super(pos, size, 0, renderOrder, canvasSize, useWebGL);
507
+
508
+ /** @property {TileInfo|undefined} - Default tile info for layer
509
+ * @type {TileInfo|undefined} */
510
+ this.tileInfo = undefined;
511
+ /** @property {Array<TileLayerData>} - Array of tile data for the layer */
512
+ this.data = [];
513
+ /** @property {boolean} - Is this layer using a webgl texture? */
514
+ this.isUsingWebGL = false;
515
+ // which side holds the whole layer, set by a full redraw, undefined before the first one; a partial redraw
516
+ // or a render that finds WebGL turned on or off since then draws it all again on the side now in use
517
+ this.tilesInWebGL = undefined;
518
+ /** @property {boolean} - Show this layer's bounds and values when the debug overlay's Debug Tiles is on,
519
+ * turn it off for layers that only add noise */
520
+ this.debugShow = true;
521
+
522
+ if (tileInfo)
523
+ {
524
+ // set tile info
525
+ this.tileInfo = tileInfo.frame(0);
526
+ this.tileInfo.bleed = 0; // disable bleed for tile layers
527
+ }
528
+
529
+ // init tile data
530
+ for (let j = this.size.area(); j--;)
531
+ this.data.push(new TileLayerData);
532
+
533
+ if (headlessMode)
534
+ {
535
+ // disable rendering in headless mode
536
+ this.render = ()=> {};
537
+ this.redraw = ()=> {};
538
+ this.redrawStart = ()=> {};
539
+ this.redrawEnd = ()=> {};
540
+ this.drawTileData = ()=> {};
541
+ this.redrawTileData = ()=> {};
542
+ this.drawLayerTile = ()=> {};
543
+ this.drawLayerRect = ()=> {};
544
+ this.drawTile = ()=> {};
545
+ this.drawRect = ()=> {};
546
+ this.clearLayerRect = ()=> {};
547
+ }
548
+ }
549
+
550
+ /** Set data at a given position in the array
551
+ * @param {Vector2} layerPos - Local position in array
552
+ * @param {TileLayerData} data - Data to set
553
+ * @param {boolean} [redraw] - Force the tile to redraw if true */
554
+ setData(layerPos, data, redraw=false)
555
+ {
556
+ ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
557
+ ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
558
+ layerPos = layerPos.floor();
559
+
560
+ if (!layerPos.arrayCheck(this.size)) return;
561
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
562
+
563
+ if (!redraw) return;
564
+ const isRedraw = drawContext === this.context;
565
+ isRedraw ? this.drawTileData(layerPos) : this.redrawTileData(layerPos);
566
+ }
567
+
568
+ /** Clear data at a given position in the array
569
+ * @param {Vector2} layerPos - Local position in array
570
+ * @param {boolean} [redraw] - Force the tile to redraw if true */
571
+ clearData(layerPos, redraw=false)
572
+ { this.setData(layerPos, new TileLayerData, redraw) }
573
+
574
+ /** Get data at a given position in the array
575
+ * @param {Vector2} layerPos - Local position in array
576
+ * @return {TileLayerData|undefined} */
577
+ getData(layerPos)
578
+ {
579
+ ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
580
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
581
+ }
582
+
583
+ // Update the tile layer, a layer has no physics
584
+ update() {}
585
+
586
+ // Render the tile layer, called automatically by the engine
587
+ render()
588
+ {
589
+ ASSERT(drawContext !== this.context, 'must call redrawEnd() after drawing tiles!');
590
+
591
+ // redraw here, not in update, which does not run while paused, if WebGL was turned off or lost, or came back
592
+ this.redrawIfSwitched();
593
+
594
+ const size = this.drawSize || this.size;
595
+ const pos = this.pos.add(size.scale(.5));
596
+ this.draw(pos, size, this.color, this.angle, this.mirror, this.additiveColor, false);
597
+ }
598
+
599
+ /** Called after this layer is redrawn, does nothing by default */
600
+ onRedraw() {}
601
+
602
+ /** Draw all the tile data to an offscreen canvas
603
+ * - This may be slow if not using webgl but only needs to be done once */
604
+ redraw()
605
+ {
606
+ this.redrawStart(true);
607
+ for (let x = this.size.x; x--;)
608
+ for (let y = this.size.y; y--;)
609
+ this.drawTileData(vec2(x,y), false);
610
+ this.isUsingWebGL && glFlush();
611
+ this.onRedraw();
612
+ this.redrawEnd();
613
+ this.tilesInWebGL = this.isUsingWebGL;
614
+ }
615
+
616
+ // draw the whole layer again if the side that holds it is not the one in use now
617
+ redrawIfSwitched()
618
+ {
619
+ if (this.tilesInWebGL !== undefined && this.hasWebGL() !== this.tilesInWebGL)
620
+ this.redraw();
621
+ }
622
+
623
+ /** Call to start the redraw process
624
+ * - This can be used to manually update parts of the level
625
+ * @param {boolean} [clear] - Should it clear the canvas before drawing */
626
+ redrawStart(clear=false)
627
+ {
628
+ if (!this.context) return;
629
+ ASSERT(drawContext !== this.context);
630
+ clear || this.redrawIfSwitched(); // a partial redraw goes on top of the whole layer on the side in use
631
+
632
+ // save current render settings
633
+ /** @type {[CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D, Vector2, Vector2, number, number, Color, Shader|undefined]} */
634
+ this.savedRenderSettings = [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader];
635
+ setShader(); // the tiles are drawn plain, a layer's own Shader applies when the layer is drawn
636
+ // a redraw from inside another target's pass, like the light system's shadow map, draws the tiles in color
637
+ // and hands that target back after
638
+ this.savedRenderTarget = [glRenderTarget, glColorMask, glColorAdditive, glSkipScreenSpace];
639
+ glColorMask = -1;
640
+ glColorAdditive = 0;
641
+ glSkipScreenSpace = false; // screen space is the layer's own pixels here
642
+
643
+ // set the draw canvas and context to this layer
644
+ // use camera settings to match this layer's canvas
645
+ drawContext = this.context;
646
+ const tileSize = this.tileInfo?.size ?? vec2(1);
647
+ mainCanvasSize = this.size.multiply(tileSize);
648
+ canvasClearColor = CLEAR_BLACK;
649
+ cameraPos = this.size.multiply(tileSize).scale(.5);
650
+ cameraScale = 1;
651
+ cameraAngle = 0; // the tiles are drawn flat, the world camera turns the whole layer later
652
+
653
+ // set render target to this layer
654
+ this.isUsingWebGL = this.hasWebGL();
655
+ if (this.isUsingWebGL)
656
+ glSetRenderTarget(this.textureInfo.glTexture, clear);
657
+ else
658
+ {
659
+ if (clear)
660
+ {
661
+ // clear and set size
662
+ this.canvas.width = mainCanvasSize.x;
663
+ this.canvas.height = mainCanvasSize.y;
664
+ }
665
+ // disable smoothing for pixel art, after the resize which resets it
666
+ this.context.imageSmoothingEnabled = !tilesPixelated;
667
+ }
668
+ }
669
+
670
+ /** Call to end the redraw process */
671
+ redrawEnd()
672
+ {
673
+ if (!this.context) return;
674
+ ASSERT(drawContext === this.context);
675
+
676
+ // set stuff back to normal, the camera first, so a target that was drawing before gets its own transform back
677
+ [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader] = this.savedRenderSettings;
678
+ const [target, colorMask, colorAdditive, skipScreenSpace] = this.savedRenderTarget;
679
+ if (this.isUsingWebGL)
680
+ glSetRenderTarget(target);
681
+ glColorMask = colorMask;
682
+ glColorAdditive = colorAdditive;
683
+ glSkipScreenSpace = skipScreenSpace;
684
+ }
685
+
686
+ /** Draw the tile at a given position in the tile layer
687
+ * This can be used to clear out tiles when they are destroyed
688
+ * Tiles can also be redrawn if inside a redrawStart/End block
689
+ * @param {Vector2} layerPos
690
+ * @param {boolean} [clear] - should the old tile be cleared out
691
+ */
692
+ drawTileData(layerPos, clear=true)
693
+ {
694
+ if (!this.context) return;
695
+ ASSERT(drawContext === this.context, 'must call redrawStart() before drawing tiles');
696
+
697
+ // clear out where the tile was, can be skipped for fully opaque tiles
698
+ const cellPixels = this.tileInfo?.size ?? vec2(1);
699
+ const drawPos = layerPos.multiply(cellPixels);
700
+ clear && this.clearLayerRect(drawPos, cellPixels);
701
+
702
+ // draw the tile if it has layer data, an empty cell has no tile and tile 0 is a tile like any other
703
+ const d = this.getData(layerPos);
704
+ if (!d || d.tile === undefined) return;
705
+
706
+ // a tileset packed by loadSprite keeps its own columns, counted from its first tile, not the sheet's grid
707
+ const t = this.tileInfo, tileInfo = t && (t.columns ? t.frame(d.tile) : t.index(d.tile));
708
+ this.drawLayerTile(drawPos, cellPixels, tileInfo, d.color, d.direction*PI/2, d.mirror);
709
+ }
710
+
711
+ /** Draw the tile at a given position in the tile layer
712
+ * This can be used to clear tiles when they are destroyed
713
+ * For better performance use drawTileData inside a redrawStart/End block
714
+ * @param {Vector2} layerPos
715
+ * @param {boolean} [clear] - should the old tile be cleared
716
+ */
717
+ redrawTileData(layerPos, clear=true)
718
+ {
719
+ if (!this.context) return;
720
+ ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
721
+
722
+ this.redrawStart();
723
+ this.drawTileData(layerPos, clear);
724
+ this.redrawEnd();
725
+ }
726
+
727
+ /** Draw textured tile in layer space
728
+ * @param {Vector2} pos - Position in pixel coordinates
729
+ * @param {Vector2} [size=vec2(1)] - Size of the tile
730
+ * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
731
+ * @param {Color} [color=WHITE] - Color to modulate with
732
+ * @param {number} [angle] - Angle to rotate by
733
+ * @param {boolean} [mirror] - Is image flipped along the Y axis?
734
+ * @param {Color} [additiveColor] - Additive color to be applied if any */
735
+ drawLayerTile(pos, size=vec2(1), tileInfo, color=WHITE,
736
+ angle=0, mirror, additiveColor)
737
+ {
738
+ const drawPos = pos.add(size.scale(.5));
739
+ drawTile(drawPos, size, tileInfo, color, angle, mirror, additiveColor, this.isUsingWebGL, false);
740
+ }
741
+
742
+ /** Draw a rectangle in layer space
743
+ * @param {Vector2} pos
744
+ * @param {Vector2} size
745
+ * @param {Color} [color=WHITE] - Color to modulate with
746
+ * @param {number} [angle] - Angle to rotate by
747
+ */
748
+ drawLayerRect(pos, size, color, angle=0)
749
+ { this.drawLayerTile(pos, size, undefined, color, angle); }
750
+
751
+ /** Draw a tile onto the layer canvas in world space
752
+ * @param {Vector2} pos
753
+ * @param {Vector2} [size=vec2(1)]
754
+ * @param {TileInfo} [tileInfo]
755
+ * @param {Color} [color=WHITE]
756
+ * @param {number} [angle]
757
+ * @param {boolean} [mirror] */
758
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
759
+ {
760
+ const tileSize = this.tileInfo?.size ?? vec2(1); // a layer made without a tile info draws a pixel a cell
761
+ pos = pos.subtract(this.pos).multiply(tileSize);
762
+ size = size.multiply(tileSize);
763
+ // a screen position is the center of a pixel, so the layer pixel coordinate moves back half a pixel
764
+ pos.x -= .5;
765
+ pos.y = this.canvas.height - pos.y - .5;
766
+
767
+ // draw the tile onto the layer canvas
768
+ // in color and handing back a target that was drawing before, like the light system's shadow map
769
+ const oldMainCanvasSize = mainCanvasSize, oldTarget = glRenderTarget, oldColorMask = glColorMask;
770
+ const oldSkip = glSkipScreenSpace, oldColorAdditive = glColorAdditive, oldShader = glCustomShader;
771
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
772
+ glColorMask = -1;
773
+ glColorAdditive = 0;
774
+ setShader(); // plain, as a redraw draws, a layer's own Shader applies when the layer is drawn
775
+ glSkipScreenSpace = false; // its screen space is the layer's own canvas
776
+ const useWebGL = this.hasWebGL();
777
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
778
+ const drawContext = useWebGL ? undefined : this.context;
779
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
780
+ mainCanvasSize = oldMainCanvasSize;
781
+ useWebGL && glSetRenderTarget(oldTarget);
782
+ glColorMask = oldColorMask;
783
+ glColorAdditive = oldColorAdditive;
784
+ glSkipScreenSpace = oldSkip;
785
+ setShader(oldShader);
786
+ }
787
+
788
+ /** Draw a rectangle onto the layer canvas in world space
789
+ * @param {Vector2} pos
790
+ * @param {Vector2} [size=vec2(1)]
791
+ * @param {Color} [color=WHITE]
792
+ * @param {number} [angle] */
793
+ drawRect(pos, size, color, angle)
794
+ { this.drawTile(pos, size, undefined, color, angle); }
795
+
796
+ /** Clear a rectangle in layer space
797
+ * @param {Vector2} pos - position in pixel coordinates
798
+ * @param {Vector2} size
799
+ */
800
+ clearLayerRect(pos, size)
801
+ {
802
+ ASSERT(drawContext === this.context, 'must call redrawStart() before clearing tiles');
803
+
804
+ const x = pos.x, y = this.canvas.height - pos.y - size.y;
805
+ const useWebGL = this.hasWebGL();
806
+ if (useWebGL)
807
+ glClearRect(x, y, size.x, size.y);
808
+ else
809
+ this.context.clearRect(x, y, size.x, size.y);
810
+ }
811
+ }
812
+
813
+ ///////////////////////////////////////////////////////////////////////////////
814
+ /**
815
+ * Tile Collision Layer - a tile layer with collision
816
+ * - adds collision data and functions to TileLayer
817
+ * - there can be multiple tile collision layers
818
+ * - its pos must be whole numbers, so its cells line up with the world grid objects land on
819
+ * @extends TileLayer
820
+ * @memberof TileLayers
821
+ */
822
+ class TileCollisionLayer extends TileLayer
823
+ {
824
+ /** Create a tile layer object
825
+ * @param {Vector2} pos - World space position
826
+ * @param {Vector2} size - World space size
827
+ * @param {TileInfo} [tileInfo] - Tile info for layer, tile() by default, none when no image is loaded
828
+ * @param {number} [renderOrder] - Objects are sorted by renderOrder
829
+ * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering
830
+ */
831
+ constructor(pos, size, tileInfo=tileLayerDefaultTile(), renderOrder=0, useWebGL=true)
832
+ {
833
+ super(pos, size.floor(), tileInfo, renderOrder, useWebGL);
834
+
835
+ /** @property {Array<number>} - The tile collision grid
836
+ * @type {Array<number>} */
837
+ this.collisionData = [];
838
+ this.initCollision(this.size);
839
+
840
+ // keep track of all collision layers
841
+ tileCollisionLayers.push(this);
842
+
843
+ /** @property {boolean} - Solid layers block objects and particles, the solidOnly tests skip the others */
844
+ this.isSolid = true;
845
+ /** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn
846
+ * as the layer shows them, so a floor in the same layer stays lit; false casts every tile */
847
+ this.shadowSolidOnly = true;
848
+ }
849
+
850
+ /** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of
851
+ * them in one draw from that part of the layer's texture, so see through pixels in a tile cast nothing;
852
+ * every tile when shadowSolidOnly is off, or when the layer is turned or mirrored */
853
+ renderShadow()
854
+ {
855
+ if (!this.shadowSolidOnly || this.angle || this.mirror)
856
+ return super.renderShadow();
857
+
858
+ // the cells in view, which in the shadow pass is the shadow map
859
+ const size = this.size, drawSize = this.drawSize || size;
860
+ const cellWorld = drawSize.divide(size), cellPixels = this.tileInfo ? this.tileInfo.size : vec2(1);
861
+ const view = getCameraSize().scale(.5), low = cameraPos.subtract(view), high = cameraPos.add(view);
862
+ const x0 = max(0, floor((low.x - this.pos.x) / cellWorld.x)), x1 = min(size.x, ceil((high.x - this.pos.x) / cellWorld.x));
863
+ const y0 = max(0, floor((low.y - this.pos.y) / cellWorld.y)), y1 = min(size.y, ceil((high.y - this.pos.y) / cellWorld.y));
864
+ const textureHeight = size.y * cellPixels.y, useWebGL = this.hasWebGL();
865
+ for (let y = y0; y < y1; ++y)
866
+ for (let x = x0; x < x1; ++x)
867
+ {
868
+ if (!this.collisionData[y*size.x + x]) continue;
869
+ let end = x + 1; // a run of solid cells along the row
870
+ while (end < x1 && this.collisionData[y*size.x + end]) ++end;
871
+ const count = end - x;
872
+ const tileInfo = new TileInfo(vec2(x*cellPixels.x, textureHeight - (y+1)*cellPixels.y),
873
+ vec2(count*cellPixels.x, cellPixels.y), this.textureInfo, 0, 0);
874
+ const pos = vec2(this.pos.x + (x + count/2)*cellWorld.x, this.pos.y + (y + .5)*cellWorld.y);
875
+ drawTile(pos, vec2(count*cellWorld.x, cellWorld.y), tileInfo, this.color, 0, false, undefined, useWebGL, false);
876
+ x = end;
877
+ }
878
+ }
879
+
880
+ /** Destroy this tile layer
881
+ * @param {boolean} [immediate] - Remove it now, as EngineObject.destroy does, children included */
882
+ destroy(immediate=false)
883
+ {
884
+ if (this.destroyed) return;
885
+
886
+ // remove from collision layers array and destroy
887
+ const index = tileCollisionLayers.indexOf(this);
888
+ ASSERT(index >= 0, 'tile collision layer not found in array');
889
+ index >= 0 && tileCollisionLayers.splice(index, 1);
890
+ super.destroy(immediate);
891
+ }
892
+
893
+ /** Clear and initialize tile collision, the size is the layer's own, the tile data and canvas keep it
894
+ * @param {Vector2} size - width and height of tile collision 2d grid */
895
+ initCollision(size)
896
+ {
897
+ ASSERT(isVector2(size), 'size must be a Vector2');
898
+ ASSERT(!this.collisionData.length || (size.x|0) === this.size.x && (size.y|0) === this.size.y, 'initCollision cannot resize a layer');
899
+ this.size = size.floor();
900
+ this.collisionData = new Array(this.size.area()).fill(0);
901
+ }
902
+
903
+ /** Set tile collision data for a given cell in the layer
904
+ * @param {Vector2} layerPos
905
+ * @param {number} [data] */
906
+ setCollisionData(layerPos, data=1)
907
+ {
908
+ ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
909
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
910
+ layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
911
+ }
912
+
913
+ /** Clear tile collision data for a given cell in the layer
914
+ * @param {Vector2} layerPos */
915
+ clearCollisionData(layerPos)
916
+ { this.setCollisionData(layerPos, 0); }
917
+
918
+ /** Get tile collision data for a given cell in the layer
919
+ * @param {Vector2} layerPos
920
+ * @return {number} */
921
+ getCollisionData(layerPos)
922
+ {
923
+ ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
924
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
925
+ return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
926
+ }
927
+
928
+ /** Check if collision with another object should occur
929
+ * @param {Vector2} pos
930
+ * @param {Vector2} [size=vec2()]
931
+ * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
932
+ * @return {boolean} */
933
+ collisionTest(pos, size=new Vector2, callbackObject)
934
+ {
935
+ ASSERT(isVector2(pos) && isVector2(size), 'pos and size must be Vector2s');
936
+ tileCollisionAssertWhole(this);
937
+ const collisionTest = tileCollisionTester(callbackObject);
938
+
939
+ // check any tiles in the area for collision
940
+ const posX = pos.x - this.pos.x;
941
+ const posY = pos.y - this.pos.y;
942
+ // reject AABBs entirely past either edge; without this, the negative
943
+ // side leaks into row/col 0 because minX/minY clamp to 0 and the
944
+ // point-test floor below forces maxX/maxY up to 1
945
+ if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
946
+ if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
947
+ const minX = max(posX - size.x/2|0, 0);
948
+ const minY = max(posY - size.y/2|0, 0);
949
+ // a zero size is a point test, one cell even when pos lands exactly on an integer boundary
950
+ const maxX = min(size.x ? posX + size.x/2 : minX + 1, this.size.x);
951
+ const maxY = min(size.y ? posY + size.y/2 : minY + 1, this.size.y);
952
+ for (let y = minY; y < maxY; ++y)
953
+ for (let x = minX; x < maxX; ++x)
954
+ {
955
+ // check if the object should collide with this tile, the callback gets its own vector, one it can keep
956
+ const tileData = this.collisionData[y*this.size.x+x];
957
+ if (tileData && collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
958
+ return true;
959
+ }
960
+ return false;
961
+ }
962
+
963
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
964
+ * The point will be inside the colliding tile if it hits (may have a tiny shift)
965
+ * @param {Vector2} posStart
966
+ * @param {Vector2} posEnd
967
+ * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined
968
+ * @param {Vector2} [normal] - Optional normal of the surface hit
969
+ * @return {Vector2|undefined} */
970
+ collisionRaycast(posStart, posEnd, callbackObject, normal)
971
+ {
972
+ ASSERT(isVector2(posStart) && isVector2(posEnd), 'positions must be Vector2s');
973
+ tileCollisionAssertWhole(this);
974
+ const collisionTest = tileCollisionTester(callbackObject);
975
+ // the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits
976
+ const offset = this.pos;
977
+ const testFunction = (pos)=>
978
+ {
979
+ const tileData = this.getCollisionData(pos);
980
+ return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
981
+ }
982
+ const hitPos = lineTest(posStart.subtract(offset), posEnd.subtract(offset), testFunction, normal);
983
+ if (hitPos)
984
+ hitPos.x += offset.x, hitPos.y += offset.y;
985
+ if (debugRaycast && hitPos)
986
+ {
987
+ const tilePos = hitPos.floor().add(vec2(.5));
988
+ debugRect(tilePos, vec2(1), '#f008', 0, 0, false, false);
989
+ debugLine(posStart, posEnd, '#00f', .02, 0, false);
990
+ debugLine(posStart, hitPos, '#f00', .02, 0, false);
991
+ debugPoint(hitPos, '#0f0', undefined, undefined, false);
992
+ normal && debugLine(hitPos, hitPos.add(normal), '#ff0', .02, 0, false);
993
+ }
994
+ return hitPos;
995
+ }
742
996
  }