diff --git a/CHANGELOG.md b/CHANGELOG.md index c2440f617..55909de7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). - ### Added + +- Allow tinting of `ex.Sprite`'s by setting a new `tint` property, renderers must support the tint property in order to function. + ```typescript + const imageSource = new ex.ImageSource('./path/to/image.png'); + await imageSource.load(); + const sprite = imageSource.toSprite(); + sprite.tint = ex.Color.Red; + ``` - Added `ex.Sound.getPlaybackPosition()` which returns the current playback position in seconds of the currently playing sound. - Added `ex.Sound.playbackRate` which allows developers to get/set the current rate of playback. 1.0 is the default playback rate, 2.0 is twice the speed, and 0.5 is half speed. - Added missing `ex.EaseBy` action type, uses `ex.EasingFunctions` to move relative from the current entity position. diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 5bc5551b7..99fba4956 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -368,6 +368,7 @@ var blockSprite = new ex.Sprite({ height: 49 } }); +blockSprite.tint = ex.Color.Blue; otherPointer.get(ex.TransformComponent).z = 100; otherPointer.graphics.use(blockSprite); // Create spritesheet diff --git a/sandbox/tests/parallel/index.ts b/sandbox/tests/parallel/index.ts index 1d78bff46..591211a2a 100644 --- a/sandbox/tests/parallel/index.ts +++ b/sandbox/tests/parallel/index.ts @@ -15,7 +15,40 @@ var actor = new ex.Actor({ height: 50 }); actor.graphics.use(image.toSprite()); -game.add(actor); +// game.add(actor); + + +class MyActor extends ex.Actor { + constructor() { + super({ + x: 50, + y: 50, + width: 100, + height: 100, + color: ex.Color.Green + }); + } + + async takeDamage(): Promise { + const knockBackSequence = new ex.ActionSequence(this, ctx => { + ctx.moveBy(ex.vec(100, 0), 500) + ctx.moveBy(ex.vec(-100, 0), 500) + }); + + const fadeSequence = new ex.ActionSequence(this, ctx => { + ctx.delay(100); + ctx.fade(0, 1000); + }); + const parallel = new ex.ParallelActions([knockBackSequence, fadeSequence]); + // oops runAction() doesn't return the ActionContext will fix soon + this.actions.runAction(parallel); + await this.actions.toPromise(); + } +} + +var myActor = new MyActor(); +game.add(myActor); +myActor.takeDamage(); var sequence1 = new ex.ActionSequence(actor, ctx => { diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts index 14f26ecef..2608a4494 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts @@ -18,6 +18,7 @@ export interface ExcaliburGraphicsContextOptions { export interface ExcaliburGraphicsContextState { opacity: number; z: number; + tint: Color; } export interface LineGraphicsOptions { color: Color; @@ -106,6 +107,11 @@ export interface ExcaliburGraphicsContext { */ opacity: number; + /** + * Sets the tint color to be multiplied by any images drawn, default is black 0xFFFFFFFF + */ + tint: Color; + /** * Resets the current transform to the identity matrix */ diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts index b1cb257f2..71cd491e4 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts @@ -106,6 +106,14 @@ export class ExcaliburGraphicsContext2DCanvas implements ExcaliburGraphicsContex this._state.current.opacity = value; } + public get tint(): Color { + return this._state.current.tint; + } + + public set tint(color: Color) { + this._state.current.tint = color; + } + public snapToPixel: boolean = true; public get smoothing(): boolean { diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts index dde045584..52e4a382e 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts @@ -139,6 +139,14 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { this._state.current.opacity = value; } + public get tint(): Color { + return this._state.current.tint; + } + + public set tint(color: Color) { + this._state.current.tint = color; + } + public get width() { return this.__gl.canvas.width; } @@ -281,6 +289,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { this.getTransform().clone(drawCall.transform); drawCall.state.z = this._state.current.z; drawCall.state.opacity = this._state.current.opacity; + drawCall.state.tint = this._state.current.tint; drawCall.args = args; this._drawCalls.push(drawCall); } else { diff --git a/src/engine/Graphics/Context/draw-call.ts b/src/engine/Graphics/Context/draw-call.ts index 058842859..5c7c5e47c 100644 --- a/src/engine/Graphics/Context/draw-call.ts +++ b/src/engine/Graphics/Context/draw-call.ts @@ -1,3 +1,4 @@ +import { Color } from '../../Color'; import { Matrix } from '../../Math/matrix'; import { ExcaliburGraphicsContextState } from './ExcaliburGraphicsContext'; @@ -8,7 +9,8 @@ export class DrawCall { public transform: Matrix = Matrix.identity(); public state: ExcaliburGraphicsContextState = { z: 0, - opacity: 1 + opacity: 1, + tint: Color.White }; public args: any[]; } \ No newline at end of file diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl b/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl index 6db50d762..e4502dd0b 100644 --- a/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl +++ b/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl @@ -13,6 +13,8 @@ uniform sampler2D u_textures[%%count%%]; // Opacity in float v_opacity; +in vec4 v_tint; + out vec4 fragColor; void main() { @@ -27,5 +29,5 @@ void main() { color.rgb = color.rgb * v_opacity; color.a = color.a * v_opacity; - fragColor = color; + fragColor = color * v_tint; } \ No newline at end of file diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.ts b/src/engine/Graphics/Context/image-renderer/image-renderer.ts index a948c8888..841618d75 100644 --- a/src/engine/Graphics/Context/image-renderer/image-renderer.ts +++ b/src/engine/Graphics/Context/image-renderer/image-renderer.ts @@ -56,7 +56,7 @@ export class ImageRenderer implements RendererPlugin { // Setup memory layout this._buffer = new VertexBuffer({ - size: 6 * 4 * this._maxImages, // 6 components * 4 verts + size: 10 * 4 * this._maxImages, // 10 components * 4 verts type: 'dynamic' }); this._layout = new VertexLayout({ @@ -66,7 +66,8 @@ export class ImageRenderer implements RendererPlugin { ['a_position', 2], ['a_opacity', 1], ['a_texcoord', 2], - ['a_textureIndex', 1] + ['a_textureIndex', 1], + ['a_tint', 4] ] }); @@ -187,6 +188,8 @@ export class ImageRenderer implements RendererPlugin { bottomRight.y = ~~bottomRight.y; } + const tint = this._context.tint; + const textureId = this._getTextureIdForImage(image); const potWidth = ensurePowerOfTwo(image.width || width); const potHeight = ensurePowerOfTwo(image.height || height); @@ -206,6 +209,10 @@ export class ImageRenderer implements RendererPlugin { vertexBuffer[this._vertexIndex++] = uvx0; vertexBuffer[this._vertexIndex++] = uvy0; vertexBuffer[this._vertexIndex++] = textureId; + vertexBuffer[this._vertexIndex++] = tint.r / 255; + vertexBuffer[this._vertexIndex++] = tint.g / 255; + vertexBuffer[this._vertexIndex++] = tint.b / 255; + vertexBuffer[this._vertexIndex++] = tint.a; // (0, 1) - 1 vertexBuffer[this._vertexIndex++] = bottomLeft.x; @@ -214,6 +221,10 @@ export class ImageRenderer implements RendererPlugin { vertexBuffer[this._vertexIndex++] = uvx0; vertexBuffer[this._vertexIndex++] = uvy1; vertexBuffer[this._vertexIndex++] = textureId; + vertexBuffer[this._vertexIndex++] = tint.r / 255; + vertexBuffer[this._vertexIndex++] = tint.g / 255; + vertexBuffer[this._vertexIndex++] = tint.b / 255; + vertexBuffer[this._vertexIndex++] = tint.a; // (1, 0) - 2 vertexBuffer[this._vertexIndex++] = topRight.x; @@ -222,6 +233,10 @@ export class ImageRenderer implements RendererPlugin { vertexBuffer[this._vertexIndex++] = uvx1; vertexBuffer[this._vertexIndex++] = uvy0; vertexBuffer[this._vertexIndex++] = textureId; + vertexBuffer[this._vertexIndex++] = tint.r / 255; + vertexBuffer[this._vertexIndex++] = tint.g / 255; + vertexBuffer[this._vertexIndex++] = tint.b / 255; + vertexBuffer[this._vertexIndex++] = tint.a; // (1, 1) - 3 vertexBuffer[this._vertexIndex++] = bottomRight.x; @@ -230,6 +245,10 @@ export class ImageRenderer implements RendererPlugin { vertexBuffer[this._vertexIndex++] = uvx1; vertexBuffer[this._vertexIndex++] = uvy1; vertexBuffer[this._vertexIndex++] = textureId; + vertexBuffer[this._vertexIndex++] = tint.r / 255; + vertexBuffer[this._vertexIndex++] = tint.g / 255; + vertexBuffer[this._vertexIndex++] = tint.b / 255; + vertexBuffer[this._vertexIndex++] = tint.a; } hasPendingDraws(): boolean { @@ -247,7 +266,7 @@ export class ImageRenderer implements RendererPlugin { this._shader.use(); // Bind the memory layout and upload data - this._layout.use(true, 4 * 6 * this._imageCount); + this._layout.use(true, 4 * 10 * this._imageCount); // Update ortho matrix uniform this._shader.setUniformMatrix('u_matrix', this._context.ortho); diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl b/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl index 0e9eaaab7..43100264c 100644 --- a/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl +++ b/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl @@ -13,6 +13,9 @@ out vec2 v_texcoord; in lowp float a_textureIndex; out lowp float v_textureIndex; +in vec4 a_tint; +out vec4 v_tint; + uniform mat4 u_matrix; void main() { @@ -25,4 +28,6 @@ void main() { v_texcoord = a_texcoord; // Pass through the texture number to the fragment shader v_textureIndex = a_textureIndex; + // Pass through the tint + v_tint = a_tint; } \ No newline at end of file diff --git a/src/engine/Graphics/Context/state-stack.ts b/src/engine/Graphics/Context/state-stack.ts index 0343c8021..dba9a44aa 100644 --- a/src/engine/Graphics/Context/state-stack.ts +++ b/src/engine/Graphics/Context/state-stack.ts @@ -1,3 +1,4 @@ +import { Color } from '../../Color'; import { ExcaliburGraphicsContextState } from './ExcaliburGraphicsContext'; export class StateStack { @@ -7,14 +8,16 @@ export class StateStack { private _getDefaultState() { return { opacity: 1, - z: 0 + z: 0, + tint: Color.White }; } private _cloneState() { return { opacity: this._currentState.opacity, - z: this._currentState.z + z: this._currentState.z, + tint: this._currentState.tint.clone() }; } diff --git a/src/engine/Graphics/Graphic.ts b/src/engine/Graphics/Graphic.ts index 120a3f94e..28ac9ad81 100644 --- a/src/engine/Graphics/Graphic.ts +++ b/src/engine/Graphics/Graphic.ts @@ -1,7 +1,7 @@ import { Vector, vec } from '../Math/vector'; import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; import { BoundingBox } from '../Collision/BoundingBox'; -import { Matrix } from '..'; +import { Color, Matrix } from '..'; import { watch } from '../Util/Watch'; export interface GraphicOptions { @@ -14,7 +14,7 @@ export interface GraphicOptions { */ height?: number; /** - * SHould the graphic be flipped horizontally + * Should the graphic be flipped horizontally */ flipHorizontal?: boolean; /** @@ -33,6 +33,10 @@ export interface GraphicOptions { * The opacity of the graphic */ opacity?: number; + /** + * The tint of the graphic, this color will be multiplied by the original pixel colors + */ + tint?: Color; /** * The origin of the drawing in pixels to use when applying transforms, by default it will be the center of the image */ @@ -51,6 +55,8 @@ export abstract class Graphic { private static _ID: number = 0; readonly id = Graphic._ID++; + public tint: Color = null; + public transform: Matrix = Matrix.identity(); private _transformStale = true; public isStale() { @@ -235,6 +241,9 @@ export abstract class Graphic { ex.multiply(this.transform); // it is important to multiply alphas so graphics respect the current context ex.opacity = ex.opacity * this.opacity; + if (this.tint) { + ex.tint = this.tint; + } } protected _rotate(ex: ExcaliburGraphicsContext | Matrix) { diff --git a/src/engine/Graphics/Text.ts b/src/engine/Graphics/Text.ts index 7f915fea8..462c4269a 100644 --- a/src/engine/Graphics/Text.ts +++ b/src/engine/Graphics/Text.ts @@ -115,6 +115,7 @@ export class Text extends Graphic { this.font.origin = this.origin; this.font.opacity = this.opacity; } + this.font.tint = this.tint; const { width, height } = this.font.measureText(this._text); this._textWidth = width; diff --git a/src/spec/SpriteSpec.ts b/src/spec/SpriteSpec.ts index c02f3012a..82066fc42 100644 --- a/src/spec/SpriteSpec.ts +++ b/src/spec/SpriteSpec.ts @@ -1,5 +1,6 @@ import * as ex from '@excalibur'; import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; +import { TestUtils } from './util/TestUtils'; describe('A Sprite Graphic', () => { let canvasElement: HTMLCanvasElement; @@ -11,7 +12,7 @@ describe('A Sprite Graphic', () => { canvasElement = document.createElement('canvas'); canvasElement.width = 100; canvasElement.height = 100; - ctx = new ex.ExcaliburGraphicsContext2DCanvas({ canvasElement, smoothing: false }); + ctx = new ex.ExcaliburGraphicsContextWebGL({ canvasElement, smoothing: false }); }); it('exists', () => { @@ -125,12 +126,28 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/source-view.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/source-view.png'); + }); + + it('can draw an sprite image with a tint', async () => { + const image = new ex.ImageSource('src/spec/images/GraphicsSpriteSpec/icon.png'); + const sut = image.toSprite(); + sut.tint = ex.Color.Green; + + await image.load(); + await image.ready; + + ctx.clear(); + sut.draw(ctx, 0, 0); + ctx.flush(); + + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/icon-tint.png'); }); it('can specify the width and height of a sprite after construction', async () => { - const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png'); + const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png', false, ex.ImageFiltering.Pixel); const sut = new ex.Sprite({ image, sourceView: { @@ -149,12 +166,13 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/change-size.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/change-size.png'); }); it('can specify the width and height and scale', async () => { - const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png'); + const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png', false, ex.ImageFiltering.Pixel); const sut = new ex.Sprite({ image, sourceView: { @@ -175,9 +193,11 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); expect(sut.width).toBe(128); expect(sut.height).toBe(128); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/change-size-and-scale.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)) + .toEqualImage('src/spec/images/GraphicsSpriteSpec/change-size-and-scale.png'); }); it('can specify a source view of an image by default is same dimension as the source', async () => { @@ -208,12 +228,13 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/source-view.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/source-view.png'); }); it('can specify a source view of an image and a dest view dimension is destination', async () => { - const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png'); + const image = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png', false, ex.ImageFiltering.Pixel); const sut = new ex.Sprite({ image, sourceView: { @@ -244,8 +265,9 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-size.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-size.png'); }); it('can specify only a dest view dimension, infers native size for source view', async () => { @@ -277,7 +299,8 @@ describe('A Sprite Graphic', () => { ctx.clear(); sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + ctx.flush(); - await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-view.png'); + await expectAsync(TestUtils.flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-view.png'); }); }); diff --git a/src/spec/images/GraphicsSpriteSpec/dest-view.png b/src/spec/images/GraphicsSpriteSpec/dest-view.png index e1ff10987..474a7d46c 100644 Binary files a/src/spec/images/GraphicsSpriteSpec/dest-view.png and b/src/spec/images/GraphicsSpriteSpec/dest-view.png differ diff --git a/src/spec/images/GraphicsSpriteSpec/icon-tint.png b/src/spec/images/GraphicsSpriteSpec/icon-tint.png new file mode 100644 index 000000000..1cbd012fb Binary files /dev/null and b/src/spec/images/GraphicsSpriteSpec/icon-tint.png differ diff --git a/src/spec/images/GraphicsSpriteSpec/icon.png b/src/spec/images/GraphicsSpriteSpec/icon.png new file mode 100644 index 000000000..3c303d20a Binary files /dev/null and b/src/spec/images/GraphicsSpriteSpec/icon.png differ