diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c4e10d4..808264db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Breaking Changes +- `ex.Gif` transparent color constructor arg is removed in favor of the built in Gif file mechanism - Remove core-js dependency, it is no longer necessary in modern browsers. Technically a breaking change for older browsers - `ex.Particle` and `ex.ParticleEmitter` now have an API that looks like modern Excalibur APIs * `particleSprite` is renamed to `graphic` @@ -47,6 +48,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Deprecated + - `Vector.size` is deprecated, use `Vector.magnitude` instead - `ScreenShader` v_texcoord is deprecated, use v_uv. This is changed to match the materials shader API - `actor.getGlobalPos()` - use `actor.globalPos` instead @@ -55,6 +57,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- `ex.Gif` can now handle default embedded GIF frame timings - New `ex.Screen.worldToPagePixelRatio` API that will return the ratio between excalibur pixels and the HTML pixels. * Additionally excalibur will now decorate the document root with this same value as a CSS variable `--ex-pixel-ratio` * Useful for scaling HTML UIs to match your game @@ -90,6 +93,7 @@ are doing mtv adjustments during precollision. ### Fixed +- Fixed issue where `ex.Gif` was not parsing certain binary formats correctly - Fixed issue where the boot `ex.Loader` was removing pixelRatio override - Fixed `ex.RasterOptions`, it now extends `ex.GraphicsOptions` which is the underlying truth - Fixed issue where rayCast `filter` would not be called in hit order diff --git a/sandbox/tests/gif/animatedGif.ts b/sandbox/tests/gif/animatedGif.ts index b355e01bc..0fa16bfe4 100644 --- a/sandbox/tests/gif/animatedGif.ts +++ b/sandbox/tests/gif/animatedGif.ts @@ -10,10 +10,35 @@ var game = new ex.Engine({ game.currentScene.camera.zoom = 2; -var gif: ex.Gif = new ex.Gif('./sword.gif', ex.Color.Black); -var loader = new ex.Loader([gif]); +var gif: ex.Gif = new ex.Gif('./loading-screen.gif'); +var gif2: ex.Gif = new ex.Gif('./sword.gif'); +var gif3: ex.Gif = new ex.Gif('./stoplight.gif'); +var loader = new ex.Loader([gif, gif2, gif3]); game.start(loader).then(() => { - var actor = new ex.Actor({ x: game.currentScene.camera.x, y: game.currentScene.camera.y, width: gif.width, height: gif.height }); - actor.graphics.add(gif.toAnimation(500)); - game.add(actor); + var stoplight = new ex.Actor({ + x: game.currentScene.camera.x + 120, + y: game.currentScene.camera.y, + width: gif3.width, + height: gif3.height + }); + stoplight.graphics.add(gif3.toAnimation()); + game.add(stoplight); + + var sword = new ex.Actor({ + x: game.currentScene.camera.x - 120, + y: game.currentScene.camera.y, + width: gif2.width, + height: gif2.height + }); + sword.graphics.add(gif2.toAnimation()); + game.add(sword); + + var loading = new ex.Actor({ + x: game.currentScene.camera.x, + y: game.currentScene.camera.y, + width: gif2.width, + height: gif2.height + }); + loading.graphics.add(gif.toAnimation()); + game.add(loading); }); diff --git a/sandbox/tests/gif/loading-screen.gif b/sandbox/tests/gif/loading-screen.gif new file mode 100644 index 000000000..e38abea94 Binary files /dev/null and b/sandbox/tests/gif/loading-screen.gif differ diff --git a/sandbox/tests/gif/stoplight.gif b/sandbox/tests/gif/stoplight.gif new file mode 100644 index 000000000..3d1c19c4a Binary files /dev/null and b/sandbox/tests/gif/stoplight.gif differ diff --git a/src/engine/Resources/Gif.ts b/src/engine/Resources/Gif.ts index 0e8ffe44a..115e02fb6 100644 --- a/src/engine/Resources/Gif.ts +++ b/src/engine/Resources/Gif.ts @@ -1,11 +1,9 @@ import { Resource } from './Resource'; import { Sprite } from '../Graphics/Sprite'; -import { Color } from '../Color'; import { SpriteSheet } from '../Graphics/SpriteSheet'; import { Animation } from '../Graphics/Animation'; import { Loadable } from '../Interfaces/Index'; import { ImageSource } from '../Graphics/ImageSource'; -import { range } from '../Math/util'; /** * The {@apilink Texture} object allows games built in Excalibur to load image resources. * {@apilink Texture} is an {@apilink Loadable} which means it can be passed to a {@apilink Loader} @@ -25,25 +23,22 @@ export class Gif implements Loadable { public height: number = 0; private _stream?: Stream; - private _gif?: ParseGif; - private _textures: ImageSource[] = []; + private _gif?: GifParser; + private _images: ImageSource[] = []; private _animation?: Animation; - private _transparentColor: Color; public data: ImageSource[] = []; + private _sprites: Sprite[] = []; /** * @param path Path to the image resource - * @param color Optionally set the color to treat as transparent the gif, by default {@apilink Color.Magenta} * @param bustCache Optionally load texture with cache busting */ constructor( public path: string, - public color: Color = Color.Magenta, bustCache = false ) { this._resource = new Resource(path, 'arraybuffer', bustCache); - this._transparentColor = color; } /** @@ -64,12 +59,15 @@ export class Gif implements Loadable { public async load(): Promise { const arraybuffer = await this._resource.load(); this._stream = new Stream(arraybuffer); - this._gif = new ParseGif(this._stream, this._transparentColor); + this._gif = new GifParser(this._stream); const images = this._gif.images.map((i) => new ImageSource(i.src, false)); - // Load all textures await Promise.all(images.map((t) => t.load())); - return (this.data = this._textures = images); + this.data = this._images = images; + this._sprites = this._images.map((image) => { + return image.toSprite(); + }); + return this.data; } public isLoaded() { @@ -81,17 +79,14 @@ export class Gif implements Loadable { * @param id */ public toSprite(id: number = 0): Sprite | null { - const sprite = this._textures[id]?.toSprite(); - return sprite ?? null; + return this._sprites[id] ?? null; } /** * Return the gif as a spritesheet */ public toSpriteSheet(): SpriteSheet | null { - const sprites: Sprite[] = this._textures.map((image) => { - return image.toSprite(); - }); + const sprites: Sprite[] = this._sprites; if (sprites.length) { return new SpriteSheet({ sprites }); } @@ -101,12 +96,22 @@ export class Gif implements Loadable { /** * Transform the GIF into an animation with duration per frame + * @param durationPerFrameMs Optionally override duration per frame */ - public toAnimation(durationPerFrameMs: number): Animation | null { - const spriteSheet = this.toSpriteSheet(); - const length = spriteSheet?.sprites.length; - if (length) { - this._animation = Animation.fromSpriteSheet(spriteSheet, range(0, length), durationPerFrameMs); + public toAnimation(durationPerFrameMs?: number): Animation | null { + const images = this._gif?.images; + if (images?.length) { + const frames = images.map((image, index) => { + return { + graphic: this._sprites[index], + duration: this._gif?.frames[index].delayMs || undefined + }; + }); + this._animation = new Animation({ + frames, + frameDuration: durationPerFrameMs + }); + return this._animation; } return null; @@ -125,12 +130,15 @@ export interface GifFrame { width: number; height: number; lctFlag: boolean; + lctBytes: [number, number, number][]; interlaced: boolean; sorted: boolean; reserved: boolean[]; lctSize: number; lzwMinCodeSize: number; pixels: number[]; + delayTime: number; + delayMs: number; } const bitsToNum = (ba: any) => { @@ -139,16 +147,16 @@ const bitsToNum = (ba: any) => { }, 0); }; -const byteToBitArr = (bite: any) => { +const byteToBitArr = (bite: number) => { const a = []; for (let i = 7; i >= 0; i--) { a.push(!!(bite & (1 << i))); } - return a; + return a as [boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean]; }; export class Stream { - data: any = null; + data: Uint8Array; len: number = 0; position: number = 0; @@ -261,41 +269,72 @@ const lzwDecode = function (minCodeSize: number, data: any) { return output; }; -// The actual parsing; returns an object with properties. -export class ParseGif { +interface GifBlock { + sentinel: number; + type: string; +} + +interface GifHeader { + sig: string; + ver: string; + width: number; + height: number; + colorResolution: number; + globalColorTableSize: number; + gctFlag: boolean; + sortedFlag: boolean; + globalColorTable: [number, number, number][]; + backgroundColorIndex: number; + pixelAspectRatio: number; // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 +} + +interface GCExtBlock extends GifBlock { + type: 'ext'; + label: number; + extType: string; + reserved: boolean[]; + disposalMethod: number; + userInputFlag: boolean; + transparentColorFlag: boolean; + delayTime: number; + transparentColorIndex: number; + terminator: number; +} + +/** + * GifParser for binary format + * + * Roughly based on the documentation https://giflib.sourceforge.net/whatsinagif/index.html + */ +export class GifParser { private _st: Stream; private _handler: any = {}; - private _transparentColor: Color; public frames: GifFrame[] = []; public images: HTMLImageElement[] = []; - public globalColorTable: any[] = []; + private _currentFrameCanvas: HTMLCanvasElement; + private _currentFrameContext: CanvasRenderingContext2D; + public globalColorTableBytes: [number, number, number][] = []; public checkBytes: number[] = []; + private _gce?: GCExtBlock; + private _hdr?: GifHeader; - constructor(stream: Stream, color: Color = Color.Magenta) { + constructor(stream: Stream) { this._st = stream; this._handler = {}; - this._transparentColor = color; + this._currentFrameCanvas = document.createElement('canvas'); + this._currentFrameContext = this._currentFrameCanvas.getContext('2d')!; this.parseHeader(); - this.parseBlock(); + this.parseBlocks(); } - // LZW (GIF-specific) - parseColorTable = (entries: any) => { + parseColorTableBytes = (entries: number) => { // Each entry is 3 bytes, for RGB. - const ct = []; + const colorTable: [number, number, number][] = []; for (let i = 0; i < entries; i++) { - const rgb: number[] = this._st.readBytes(3); - const rgba = - '#' + - rgb - .map((x: any) => { - const hex = x.toString(16); - return hex.length === 1 ? '0' + hex : hex; - }) - .join(''); - ct.push(rgba); + const rgb = this._st.readBytes(3) as [number, number, number]; + colorTable.push(rgb); } - return ct; + return colorTable; }; readSubBlocks = () => { @@ -309,18 +348,18 @@ export class ParseGif { }; parseHeader = () => { - const hdr: any = { - sig: null, - ver: null, - width: null, - height: null, - colorRes: null, - globalColorTableSize: null, - gctFlag: null, - sorted: null, + const hdr: GifHeader = { + sig: '', + ver: '', + width: 0, + height: 0, + colorResolution: 0, + globalColorTableSize: 0, + gctFlag: false, + sortedFlag: false, globalColorTable: [], - bgColor: null, - pixelAspectRatio: null // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 + backgroundColorIndex: 0, + pixelAspectRatio: 0 // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 }; hdr.sig = this._st.read(3); @@ -332,43 +371,47 @@ export class ParseGif { hdr.width = this._st.readUnsigned(); hdr.height = this._st.readUnsigned(); + this._currentFrameCanvas.width = hdr.width; + this._currentFrameCanvas.height = hdr.height; + const bits = byteToBitArr(this._st.readByte()); - hdr.gctFlag = bits.shift(); - hdr.colorRes = bitsToNum(bits.splice(0, 3)); - hdr.sorted = bits.shift(); + hdr.gctFlag = bits.shift()!; + hdr.colorResolution = bitsToNum(bits.splice(0, 3)); + hdr.sortedFlag = bits.shift()!; hdr.globalColorTableSize = bitsToNum(bits.splice(0, 3)); - hdr.bgColor = this._st.readByte(); + hdr.backgroundColorIndex = this._st.readByte(); hdr.pixelAspectRatio = this._st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 if (hdr.gctFlag) { - hdr.globalColorTable = this.parseColorTable(1 << (hdr.globalColorTableSize + 1)); - this.globalColorTable = hdr.globalColorTable; + // hdr.globalColorTable = this.parseColorTable(1 << (hdr.globalColorTableSize + 1)); + this.globalColorTableBytes = this.parseColorTableBytes(1 << (hdr.globalColorTableSize + 1)); } if (this._handler.hdr && this._handler.hdr(hdr)) { this.checkBytes.push(this._handler.hdr); } }; - parseExt = (block: any) => { - const parseGCExt = (block: any) => { + parseExt = (block: GCExtBlock) => { + const parseGCExt = (block: GCExtBlock) => { this.checkBytes.push(this._st.readByte()); // Always 4 const bits = byteToBitArr(this._st.readByte()); block.reserved = bits.splice(0, 3); // Reserved; should be 000. block.disposalMethod = bitsToNum(bits.splice(0, 3)); - block.userInput = bits.shift(); - block.transparencyGiven = bits.shift(); + block.userInputFlag = bits.shift()!; + block.transparentColorFlag = bits.shift()!; block.delayTime = this._st.readUnsigned(); - block.transparencyIndex = this._st.readByte(); + block.transparentColorIndex = this._st.readByte(); - block.terminator = this._st.readByte(); + block.terminator = this._st.readByte(); // always 0 if (this._handler.gce && this._handler.gce(block)) { this.checkBytes.push(this._handler.gce); } + return block; }; const parseComExt = (block: any) => { @@ -430,7 +473,7 @@ export class ParseGif { switch (block.label) { case 0xf9: block.extType = 'gce'; - parseGCExt(block); + this._gce = parseGCExt(block); break; case 0xfe: block.extType = 'com'; @@ -451,8 +494,8 @@ export class ParseGif { } }; - parseImg = (img: any) => { - const deinterlace = (pixels: any, width: number) => { + parseImg = (img: GifFrame) => { + const deinterlace = (pixels: number[], width: number) => { // Of course this defeats the purpose of interlacing. And it's *probably* // the least efficient way it's ever been implemented. But nevertheless... @@ -483,14 +526,14 @@ export class ParseGif { img.height = this._st.readUnsigned(); const bits = byteToBitArr(this._st.readByte()); - img.lctFlag = bits.shift(); - img.interlaced = bits.shift(); - img.sorted = bits.shift(); + img.lctFlag = bits.shift()!; + img.interlaced = bits.shift()!; + img.sorted = bits.shift()!; img.reserved = bits.splice(0, 2); img.lctSize = bitsToNum(bits.splice(0, 3)); if (img.lctFlag) { - img.lct = this.parseColorTable(1 << (img.lctSize + 1)); + img.lctBytes = this.parseColorTableBytes(1 << (img.lctSize + 1)); } img.lzwMinCodeSize = this._st.readByte(); @@ -504,15 +547,19 @@ export class ParseGif { img.pixels = deinterlace(img.pixels, img.width); } + if (this._gce?.delayTime) { + img.delayMs = this._gce.delayTime * 10; + } + this.frames.push(img); - this.arrayToImage(img); + this.arrayToImage(img, img.lctFlag ? img.lctBytes : this.globalColorTableBytes); if (this._handler.img && this._handler.img(img)) { this.checkBytes.push(this._handler); } }; - public parseBlock = () => { - const block = { + public parseBlocks = () => { + const block: GifBlock = { sentinel: this._st.readByte(), type: '' }; @@ -520,11 +567,11 @@ export class ParseGif { switch (blockChar) { case '!': block.type = 'ext'; - this.parseExt(block); + this.parseExt(block as GCExtBlock); break; case ',': block.type = 'img'; - this.parseImg(block); + this.parseImg(block as any); break; case ';': block.type = 'eof'; @@ -537,37 +584,51 @@ export class ParseGif { } if (block.type !== 'eof') { - this.parseBlock(); + this.parseBlocks(); } }; - arrayToImage = (frame: GifFrame) => { - let count = 0; - const c = document.createElement('canvas')!; - c.id = count.toString(); - c.width = frame.width; - c.height = frame.height; - count++; - const context = c.getContext('2d')!; - const pixSize = 1; - let y = 0; - let x = 0; - for (let i = 0; i < frame.pixels.length; i++) { - if (x % frame.width === 0) { - y++; - x = 0; - } - if (this.globalColorTable[frame.pixels[i]] === this._transparentColor.toHex()) { - context.fillStyle = `rgba(0, 0, 0, 0)`; + arrayToImage = (frame: GifFrame, colorTable: [number, number, number][]) => { + const canvas = document.createElement('canvas')!; + canvas.width = frame.width; + canvas.height = frame.height; + const context = canvas.getContext('2d')!; + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + + let transparentColorIndex = -1; + if (this._gce?.transparentColorFlag) { + transparentColorIndex = this._gce.transparentColorIndex; + } + + for (let pixel = 0; pixel < frame.pixels.length; pixel++) { + const colorIndex = frame.pixels[pixel]; + const color = colorTable[colorIndex]; + if (colorIndex === transparentColorIndex) { + imageData.data.set([0, 0, 0, 0], pixel * 4); } else { - context.fillStyle = this.globalColorTable[frame.pixels[i]]; + imageData.data.set([...color, 255], pixel * 4); } - - context.fillRect(x, y, pixSize, pixSize); - x++; } + context.putImageData(imageData, 0, 0); + + // A value of 1 which tells the decoder to leave the image in place and draw the next image on top of it. + // A value of 2 would have meant that the canvas should be restored to the background color (as indicated by the logical screen descriptor). + // A value of 3 is defined to mean that the decoder should restore the canvas to its previous state before the current image was drawn. + // The behavior for values 4-7 are yet to be defined. + if (this._gce?.disposalMethod === 1 && this.images.length) { + this._currentFrameContext.drawImage(this.images.at(-1)!, 0, 0); + } else if (this._gce?.disposalMethod === 2 && this._hdr?.gctFlag) { + const bg = colorTable[this._hdr.backgroundColorIndex]; + this._currentFrameContext.fillStyle = `rgb(${bg[0]}, ${bg[1]}, ${bg[2]})`; + this._currentFrameContext.fillRect(0, 0, this._hdr.width, this._hdr.height); + } else { + this._currentFrameContext.clearRect(0, 0, this._currentFrameCanvas.width, this._currentFrameCanvas.height); + } + + this._currentFrameContext.drawImage(canvas, frame.leftPos, frame.topPos, frame.width, frame.height); + const img = new Image(); - img.src = c.toDataURL(); + img.src = this._currentFrameCanvas.toDataURL(); // default is png this.images.push(img); }; } diff --git a/src/spec/GifSpec.ts b/src/spec/GifSpec.ts index ae99bccb5..4fbdf2c52 100644 --- a/src/spec/GifSpec.ts +++ b/src/spec/GifSpec.ts @@ -13,7 +13,7 @@ describe('A Gif', () => { height: 100 }); - gif = new ex.Gif('src/spec/images/GifSpec/sword.gif', ex.Color.Black.clone()); + gif = new ex.Gif('src/spec/images/GifSpec/sword.gif'); }); afterEach(() => { engine.stop(); @@ -29,6 +29,43 @@ describe('A Gif', () => { }); }); + it('should parse gifs that have lct & anim params', async () => { + const sut = new ex.Gif('src/spec/images/GifSpec/loading-screen.gif'); + await sut.load(); + + const sprite = sut.toSprite(20); + expect(sut.isLoaded()).toBe(true); + + sprite.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/loading-frame-20.png'); + }); + + it('should parse gifs that have animations with sub frames', async () => { + const sut = new ex.Gif('src/spec/images/GifSpec/stoplight.gif'); + await sut.load(); + expect(sut.isLoaded()).toBe(true); + + const sprite2 = sut.toSprite(2); + sprite2.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-3.png'); + + const sprite1 = sut.toSprite(1); + sprite1.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-2.png'); + + const sprite0 = sut.toSprite(0); + sprite0.draw(engine.graphicsContext, 0, 0); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GifSpec/stoplight-frame-1.png'); + }); + it('should load each frame', async () => { await gif.load(); expect(gif).toBeDefined(); diff --git a/src/spec/images/GifSpec/frame1.png b/src/spec/images/GifSpec/frame1.png index b526cafa2..421d4ab87 100644 Binary files a/src/spec/images/GifSpec/frame1.png and b/src/spec/images/GifSpec/frame1.png differ diff --git a/src/spec/images/GifSpec/frame2.png b/src/spec/images/GifSpec/frame2.png index 30806c1fb..9ca7f1ffc 100644 Binary files a/src/spec/images/GifSpec/frame2.png and b/src/spec/images/GifSpec/frame2.png differ diff --git a/src/spec/images/GifSpec/loading-frame-20.png b/src/spec/images/GifSpec/loading-frame-20.png new file mode 100644 index 000000000..00c70ff9d Binary files /dev/null and b/src/spec/images/GifSpec/loading-frame-20.png differ diff --git a/src/spec/images/GifSpec/loading-screen.gif b/src/spec/images/GifSpec/loading-screen.gif new file mode 100644 index 000000000..e38abea94 Binary files /dev/null and b/src/spec/images/GifSpec/loading-screen.gif differ diff --git a/src/spec/images/GifSpec/stoplight-frame-1.png b/src/spec/images/GifSpec/stoplight-frame-1.png new file mode 100644 index 000000000..cea7d03fa Binary files /dev/null and b/src/spec/images/GifSpec/stoplight-frame-1.png differ diff --git a/src/spec/images/GifSpec/stoplight-frame-2.png b/src/spec/images/GifSpec/stoplight-frame-2.png new file mode 100644 index 000000000..f2c99cb5c Binary files /dev/null and b/src/spec/images/GifSpec/stoplight-frame-2.png differ diff --git a/src/spec/images/GifSpec/stoplight-frame-3.png b/src/spec/images/GifSpec/stoplight-frame-3.png new file mode 100644 index 000000000..8a3ecd43c Binary files /dev/null and b/src/spec/images/GifSpec/stoplight-frame-3.png differ diff --git a/src/spec/images/GifSpec/stoplight.gif b/src/spec/images/GifSpec/stoplight.gif new file mode 100644 index 000000000..3d1c19c4a Binary files /dev/null and b/src/spec/images/GifSpec/stoplight.gif differ