littlejsengine 1.18.21 → 1.18.23

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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * LittleJS Texture Sheet Plugin
3
+ * - Packs images into texture sheets as they are loaded
4
+ * - Sprites are placed automatically, callers get a TileInfo
5
+ * - Sheets are created and filled as needed
6
+ * - Sheets fill in call order, images decode in parallel
7
+ * - Animation frames keep layout and wrap across rows as needed
8
+ * - WebGL textures upload once per batch of loads
9
+ * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
10
+ * @namespace TextureSheets
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /** Width and height in pixels of texture sheets created by loadSprite
16
+ * @type {number}
17
+ * @default
18
+ * @memberof Settings */
19
+ let textureSheetSize = 2048;
20
+
21
+ /** Default padding pixels around each frame packed by loadSprite
22
+ * @type {number}
23
+ * @default
24
+ * @memberof Settings */
25
+ let textureSheetPadding = 1;
26
+
27
+ /** Array of texture sheets created by loadSprite
28
+ * @type {Array<TextureSheet>}
29
+ * @memberof TextureSheets */
30
+ let textureSheets = [];
31
+
32
+ // pending loads pack through a queue so sheets fill in call order
33
+ let textureSheetQueue = Promise.resolve();
34
+ let textureSheetPendingCount = 0;
35
+
36
+ /**
37
+ * Texture Sheet - A texture that images are packed into as they load
38
+ * Uses shelf packing, images are placed left to right then wrap to a new row
39
+ * @memberof TextureSheets
40
+ */
41
+ class TextureSheet
42
+ {
43
+ /** Create a texture sheet, called automatically by loadSprite
44
+ * @param {number} [size] - Width and height of the sheet in pixels */
45
+ constructor(size=textureSheetSize)
46
+ {
47
+ ASSERT(size > 0, 'texture sheet size must be positive');
48
+
49
+ /** @property {number} - Width and height of the sheet in pixels */
50
+ this.size = size;
51
+ /** @property {OffscreenCanvas} - Canvas holding the packed images */
52
+ this.canvas = headlessMode ? undefined : new OffscreenCanvas(size, size);
53
+ /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
54
+ this.context = this.canvas?.getContext('2d');
55
+ /** @property {TextureInfo} - The texture info for this sheet */
56
+ this.textureInfo = new TextureInfo(this.canvas);
57
+ /** @property {Vector2} - Where the next image will be packed */
58
+ this.cursor = vec2();
59
+ /** @property {number} - Height of the row being packed */
60
+ this.rowHeight = 0;
61
+ /** @property {boolean} - Has the canvas changed since the last webgl upload? */
62
+ this.glDirty = false;
63
+
64
+ if (headlessMode)
65
+ {
66
+ // tiles still need bounds when there is no canvas to measure
67
+ this.textureInfo.size = vec2(size);
68
+ this.textureInfo.sizeInverse = vec2(1/size);
69
+ }
70
+ }
71
+
72
+ /** Find a spot for an image on this sheet without drawing it
73
+ * @param {Vector2} imageSize - Size of the source image in pixels
74
+ * @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
75
+ * @param {number} [padding] - How many pixels padding around each frame
76
+ * @param {number} [sourcePadding] - How many pixels padding around each frame in the source image
77
+ * @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
78
+ tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding, sourcePadding=0)
79
+ {
80
+ ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
81
+ ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
82
+ ASSERT(isNumber(sourcePadding) && sourcePadding >= 0, 'sourcePadding must be a number >= 0');
83
+
84
+ // the source may have its own padding baked in around each frame
85
+ const sourceCellWidth = frameSize.x + sourcePadding*2;
86
+ const sourceCellHeight = frameSize.y + sourcePadding*2;
87
+ ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0,
88
+ 'image size must be a multiple of the padded frame size');
89
+
90
+ const cellWidth = frameSize.x + padding*2;
91
+ const cellHeight = frameSize.y + padding*2;
92
+ const maxColumns = this.size / cellWidth | 0;
93
+ ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');
94
+
95
+ // keep the layout of the source image, but narrow it if a row is too wide
96
+ // frames wrap down to the next row, which TileInfo.frame handles via columns
97
+ const sourceColumns = imageSize.x / sourceCellWidth;
98
+ const frameCount = sourceColumns * (imageSize.y / sourceCellHeight);
99
+ const columns = min(sourceColumns, maxColumns);
100
+ const blockWidth = columns * cellWidth;
101
+ const blockHeight = ceil(frameCount / columns) * cellHeight;
102
+
103
+ // probe the placement using locals so a failed try leaves the sheet unchanged
104
+ let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
105
+ if (x + blockWidth > this.size)
106
+ {
107
+ // start a new row if this one does not have enough space left
108
+ x = 0;
109
+ y += rowHeight;
110
+ rowHeight = 0;
111
+ }
112
+
113
+ // out of space, the caller needs to use a different sheet
114
+ if (y + blockHeight > this.size)
115
+ return undefined;
116
+
117
+ // commit the placement, tile pos points inside the padding to match how tile() works
118
+ this.cursor.x = x + blockWidth;
119
+ this.cursor.y = y;
120
+ this.rowHeight = max(rowHeight, blockHeight);
121
+ return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
122
+ }
123
+
124
+ /** Draw an image into this sheet at a tile returned by tryAdd
125
+ * @param {HTMLImageElement} image - Source image to copy from
126
+ * @param {TileInfo} tileInfo - Where to put it, from tryAdd
127
+ * @param {boolean} [update] - Upload to webgl now, pass false when batching
128
+ * @param {number} [sourcePadding] - How many pixels padding around each frame in the source image */
129
+ drawImage(image, tileInfo, update=true, sourcePadding=0)
130
+ {
131
+ ASSERT(!!this.context, 'texture sheet has no canvas');
132
+
133
+ // copy frames in order, reading the source left to right, top to bottom
134
+ // the destination wraps at tileInfo.columns which may be narrower than the source
135
+ const frameSize = tileInfo.size;
136
+ const sourceCellWidth = frameSize.x + sourcePadding*2;
137
+ const sourceCellHeight = frameSize.y + sourcePadding*2;
138
+ const sourceColumns = image.width / sourceCellWidth;
139
+ const frameCount = sourceColumns * (image.height / sourceCellHeight);
140
+ const columns = tileInfo.columns || frameCount;
141
+ const cellWidth = frameSize.x + tileInfo.padding*2;
142
+ const cellHeight = frameSize.y + tileInfo.padding*2;
143
+ for (let i = frameCount; i--;)
144
+ {
145
+ const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePadding;
146
+ const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePadding;
147
+ this.context.drawImage(image,
148
+ sourceX, sourceY, frameSize.x, frameSize.y,
149
+ tileInfo.pos.x + (i % columns) * cellWidth,
150
+ tileInfo.pos.y + (i / columns | 0) * cellHeight,
151
+ frameSize.x, frameSize.y);
152
+ }
153
+
154
+ // upload now unless the caller is batching more images
155
+ this.glDirty = true;
156
+ update && this.updateTexture();
157
+ }
158
+
159
+ /** Upload the canvas to webgl if it has changed since the last upload
160
+ * Only needed after batching, drawImage uploads automatically by default */
161
+ updateTexture()
162
+ {
163
+ if (!this.glDirty) return;
164
+ this.glDirty = false;
165
+ this.textureInfo.createWebGLTexture();
166
+ }
167
+ }
168
+
169
+ ///////////////////////////////////////////////////////////////////////////////
170
+
171
+ /** Load an image and pack it into a texture sheet
172
+ * - Returns a TileInfo immediately which is filled in when the image loads
173
+ * - Nothing is visible until it loads, use spritesReady to wait for it
174
+ * - Pass frameSize for animations, then step through them with TileInfo.frame
175
+ * - Grid images keep their layout and frames wrap down to the next row
176
+ * - Pass sourcePadding if the source image has padding baked in around frames
177
+ * @param {string} src - Image source path
178
+ * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
179
+ * @param {number} [padding] - How many pixels padding around each frame
180
+ * @param {number} [sourcePadding] - How many pixels padding around each frame in the source image
181
+ * @return {TileInfo}
182
+ * @example
183
+ * const playerTile = loadSprite('player.png'); // a single sprite
184
+ * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
185
+ * @memberof TextureSheets */
186
+ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0)
187
+ {
188
+ ASSERT(isStringLike(src), 'image src must be a string');
189
+ ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
190
+ ASSERT(isNumber(padding), 'padding must be a number');
191
+
192
+ if (isNumber(frameSize))
193
+ frameSize = vec2(frameSize);
194
+
195
+ // start with an empty tile that gets filled in when the image loads
196
+ const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
197
+ if (headlessMode) return tileInfo;
198
+
199
+ // point at a sheet right away so drawing before it loads picks up empty pixels
200
+ tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
201
+
202
+ // start decoding right away, images decode in parallel
203
+ const image = new Image;
204
+ const imagePromise = new Promise(resolve =>
205
+ {
206
+ image.onerror = image.onload = resolve;
207
+ image.crossOrigin = 'anonymous';
208
+ image.src = src;
209
+ });
210
+
211
+ // pack through a queue so sheets fill in call order, not decode order
212
+ ++textureSheetPendingCount;
213
+ textureSheetQueue = textureSheetQueue.then(async ()=>
214
+ {
215
+ await imagePromise;
216
+ if (image.width)
217
+ {
218
+ // pack onto a sheet, then fill in the tile that was already handed out,
219
+ // copying every field so nothing is missed if TileInfo gains more of them
220
+ const imageSize = vec2(image.width, image.height);
221
+ const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
222
+ Object.assign(tileInfo, tile);
223
+ sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
224
+ }
225
+ else
226
+ {
227
+ // leave the tile empty if the image failed to load
228
+ LOG('loadSprite failed to load image:', src);
229
+ }
230
+
231
+ // upload to webgl once per batch, when the last pending load finishes
232
+ if (!--textureSheetPendingCount)
233
+ textureSheets.forEach(s=> s.updateTexture());
234
+ });
235
+
236
+ return tileInfo;
237
+ }
238
+
239
+ /** Load a pre-packed texture atlas and repack it onto texture sheets
240
+ * - Supports TexturePacker json (hash and array) and Aseprite json
241
+ * - Returns an empty object which is filled with TileInfos when loaded
242
+ * - Frames are named by the json, animations are grouped automatically
243
+ * - Aseprite frame tags become animations, so do names like run_0, run_1
244
+ * - Trimmed frames are restored to their full source size when packed
245
+ * - Rotated frames are rotated back upright when packed
246
+ * @param {string} imageSrc - Atlas image path
247
+ * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
248
+ * @param {number} [padding] - How many pixels padding around each frame
249
+ * @return {Object} Object mapping frame and animation names to TileInfos
250
+ * @example
251
+ * const atlas = loadAtlas('sprites.png', 'sprites.json');
252
+ * await spritesReady();
253
+ * drawTile(pos, size, atlas.player); // a single frame
254
+ * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation
255
+ * @memberof TextureSheets */
256
+ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
257
+ {
258
+ ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
259
+ ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
260
+ ASSERT(isNumber(padding), 'padding must be a number');
261
+
262
+ const atlas = {};
263
+ if (headlessMode) return atlas;
264
+
265
+ // start fetching the json and decoding the image right away, in parallel
266
+ const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
267
+ fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
268
+ const image = new Image;
269
+ const imagePromise = new Promise(resolve =>
270
+ {
271
+ image.onerror = image.onload = resolve;
272
+ image.crossOrigin = 'anonymous';
273
+ image.src = imageSrc;
274
+ });
275
+
276
+ // pack through a queue so sheets fill in call order, not decode order
277
+ ++textureSheetPendingCount;
278
+ textureSheetQueue = textureSheetQueue.then(async ()=>
279
+ {
280
+ const data = await jsonPromise;
281
+ await imagePromise;
282
+ if (image.width && data)
283
+ {
284
+ for (const group of parseAtlas(data))
285
+ {
286
+ // reserve a block of full size cells, one per frame
287
+ const sourceSize = group.frames[0].sourceSize;
288
+ const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
289
+ const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);
290
+
291
+ // draw each frame untrimmed into its cell
292
+ const context = sheet.context;
293
+ const cellWidth = sourceSize.x + padding*2;
294
+ const cellHeight = sourceSize.y + padding*2;
295
+ group.frames.forEach((f, i)=>
296
+ {
297
+ const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
298
+ const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
299
+ if (f.rotated)
300
+ {
301
+ // stored rotated 90 degrees clockwise, draw it back upright
302
+ context.save();
303
+ context.translate(x, y);
304
+ context.rotate(-PI/2);
305
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
306
+ -f.size.y, 0, f.size.y, f.size.x);
307
+ context.restore();
308
+ }
309
+ else
310
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
311
+ x, y, f.size.x, f.size.y);
312
+ });
313
+ sheet.glDirty = true;
314
+ atlas[group.name] = tile;
315
+ }
316
+ }
317
+ else
318
+ {
319
+ // leave the atlas empty if either file failed to load
320
+ LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
321
+ }
322
+
323
+ // upload to webgl once per batch, when the last pending load finishes
324
+ if (!--textureSheetPendingCount)
325
+ textureSheets.forEach(s=> s.updateTexture());
326
+ });
327
+
328
+ return atlas;
329
+ }
330
+
331
+ /** Parse atlas json into a list of named frame groups, used by loadAtlas
332
+ * - Accepts TexturePacker json (hash and array) and Aseprite json
333
+ * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
334
+ * @param {Object} data - Parsed atlas json data
335
+ * @return {Array<Object>} List of {name, frames} groups in atlas order
336
+ * @memberof TextureSheets */
337
+ function parseAtlas(data)
338
+ {
339
+ ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');
340
+
341
+ // normalize both hash and array frame layouts into a single list
342
+ const frames = (isArray(data.frames) ?
343
+ data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
344
+ .map(([name, f])=> ({
345
+ name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
346
+ pos: vec2(f.frame.x, f.frame.y),
347
+ size: vec2(f.frame.w, f.frame.h),
348
+ offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
349
+ sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
350
+ rotated: !!f.rotated,
351
+ }));
352
+
353
+ const groups = [];
354
+ const tags = data.meta?.frameTags;
355
+ if (tags?.length)
356
+ {
357
+ // aseprite tags are authoritative, untagged frames stay individual
358
+ const tagged = new Set;
359
+ for (const tag of tags)
360
+ {
361
+ groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
362
+ for (let i = tag.from; i <= tag.to; ++i)
363
+ tagged.add(i);
364
+ }
365
+ frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
366
+ return groups;
367
+ }
368
+
369
+ // group frames that share a name stem with contiguous trailing numbers
370
+ // run_0.png and run_1.png become a 2 frame animation named run
371
+ const stems = new Map;
372
+ for (const f of frames)
373
+ {
374
+ let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
375
+ if (match && !match[2] && /\d$/.test(match[1]))
376
+ match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
377
+ const stem = match ? match[1] : f.name;
378
+ f.groupIndex = match ? Number(match[3]) : undefined;
379
+ stems.has(stem) || stems.set(stem, []);
380
+ stems.get(stem).push(f);
381
+ }
382
+ for (const [stem, list] of stems)
383
+ {
384
+ // only group 2 or more frames with contiguous indices and matching sizes
385
+ list.sort((a, b)=> a.groupIndex - b.groupIndex);
386
+ const grouped = list.length > 1 &&
387
+ list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
388
+ list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
389
+ f.sourceSize.y === list[0].sourceSize.y);
390
+ if (grouped)
391
+ groups.push({name: stem, frames: list});
392
+ else
393
+ list.forEach(f=> groups.push({name: f.name, frames: [f]}));
394
+ }
395
+ return groups;
396
+ }
397
+
398
+ /** Wait for everything started by loadSprite and loadAtlas to finish packing
399
+ * @return {Promise}
400
+ * @example
401
+ * async function gameInit()
402
+ * {
403
+ * playerTile = loadSprite('player.png');
404
+ * runTile = loadSprite('run.png', vec2(16));
405
+ * await spritesReady();
406
+ * }
407
+ * @memberof TextureSheets */
408
+ async function spritesReady()
409
+ {
410
+ // keep waiting until the queue drains, more sprites may load while waiting
411
+ while (textureSheetPendingCount)
412
+ await textureSheetQueue;
413
+ }
414
+
415
+ // create a new texture sheet and add it to the list
416
+ function textureSheetCreate()
417
+ {
418
+ const sheet = new TextureSheet;
419
+ textureSheets.push(sheet);
420
+ return sheet;
421
+ }
422
+
423
+ // use the first sheet with enough space, or make a new one
424
+ function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
425
+ {
426
+ let sheet, tile;
427
+ for (sheet of textureSheets)
428
+ if (tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding))
429
+ break;
430
+ if (!tile)
431
+ {
432
+ sheet = textureSheetCreate();
433
+ tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding);
434
+ ASSERT(!!tile, 'image is too large to fit on a texture sheet');
435
+ }
436
+ return {sheet, tile};
437
+ }
438
+
439
+ ///////////////////////////////////////////////////////////////////////////////
440
+ // Texture sheet setting setters
441
+
442
+ /** Set width and height in pixels of texture sheets created by loadSprite
443
+ * @param {number} size
444
+ * @memberof Settings */
445
+ function setTextureSheetSize(size) { textureSheetSize = size; }
446
+
447
+ /** Set default padding pixels around each frame packed by loadSprite
448
+ * @param {number} padding
449
+ * @memberof Settings */
450
+ function setTextureSheetPadding(padding) { textureSheetPadding = padding; }
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.18.21';
35
+ const engineVersion = '1.18.23';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -46,6 +46,7 @@ const enginePluginFiles =
46
46
  `${PLUGIN_FOLDER}/uiSystem.js`,
47
47
  `${PLUGIN_FOLDER}/box2d.js`,
48
48
  `${PLUGIN_FOLDER}/drawUtilities.js`,
49
+ `${PLUGIN_FOLDER}/textureSheet.js`,
49
50
  `${PLUGIN_FOLDER}/tweenSystem.js`,
50
51
  `${PLUGIN_FOLDER}/pathFinder.js`,
51
52
  `${PLUGIN_FOLDER}/threejs.js`,
package/src/engineDraw.js CHANGED
@@ -155,8 +155,9 @@ class TileInfo
155
155
  * @param {TextureInfo} [textureInfo] - Texture info to use
156
156
  * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
157
157
  * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
158
+ * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
158
159
  */
159
- constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
160
+ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
160
161
  {
161
162
  /** @property {Vector2} - Top left corner of tile in pixels */
162
163
  this.pos = pos.copy();
@@ -168,6 +169,8 @@ class TileInfo
168
169
  this.textureInfo = textureInfo;
169
170
  /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
170
171
  this.bleed = bleed;
172
+ /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
173
+ this.columns = columns;
171
174
  }
172
175
 
173
176
  /** Returns a copy of this tile offset by a vector
@@ -175,9 +178,10 @@ class TileInfo
175
178
  * @return {TileInfo}
176
179
  */
177
180
  offset(offset)
178
- { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed); }
181
+ { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
179
182
 
180
183
  /** Returns a copy of this tile offset by a number of animation frames
184
+ * Frames wrap down to the next row if columns is set
181
185
  * @param {number} frame - Offset to apply in animation frames
182
186
  * @return {TileInfo}
183
187
  */
@@ -185,9 +189,23 @@ class TileInfo
185
189
  {
186
190
  ASSERT(typeof frame === 'number');
187
191
  const w = this.size.x + this.padding*2;
188
- const x = frame*w;
189
- ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
190
- return this.offset(new Vector2(x));
192
+ const h = this.size.y + this.padding*2;
193
+ const x = (this.columns ? frame % this.columns : frame) * w;
194
+ const y = (this.columns ? frame / this.columns | 0 : 0) * h;
195
+ ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
196
+ ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
197
+ return this.offset(new Vector2(x, y));
198
+ }
199
+
200
+ /** Set how many frames per row this tile uses, so frame() can wrap
201
+ * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
202
+ * @return {TileInfo}
203
+ */
204
+ setColumns(columns=0)
205
+ {
206
+ ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
207
+ this.columns = columns;
208
+ return this;
191
209
  }
192
210
 
193
211
  /**
@@ -196,7 +214,7 @@ class TileInfo
196
214
  * @return {TileInfo}
197
215
  */
198
216
  index(index)
199
- { return tile(index, this.size, this.textureInfo, this.padding, this.bleed); }
217
+ { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
200
218
 
201
219
  /**
202
220
  * Set this tile to use a full image in a texture info
@@ -208,7 +226,7 @@ class TileInfo
208
226
  this.textureInfo = textureInfo;
209
227
  this.pos = new Vector2;
210
228
  this.size = textureInfo.size.copy();
211
- this.bleed = this.padding = 0;
229
+ this.bleed = this.padding = this.columns = 0;
212
230
  return this;
213
231
  }
214
232
  }