diff --git a/src/input/InputManager.js b/src/input/InputManager.js index d2cc52cda0..9e896ebd83 100644 --- a/src/input/InputManager.js +++ b/src/input/InputManager.js @@ -1034,9 +1034,10 @@ var InputManager = new Class({ p1.x = p0.x; p1.y = p0.y; - // Translate coordinates - var x = this.scaleManager.transformX(pageX); - var y = this.scaleManager.transformY(pageY); + // Translate coordinates using transformXY to handle CSS transforms (rotation, skew) + var transformed = this.scaleManager.transformXY(pageX, pageY, this._tempPoint); + var x = transformed.x; + var y = transformed.y; var a = pointer.smoothFactor; diff --git a/src/scale/ScaleManager.js b/src/scale/ScaleManager.js index 7210374d5b..710e35e0f8 100644 --- a/src/scale/ScaleManager.js +++ b/src/scale/ScaleManager.js @@ -270,6 +270,18 @@ var ScaleManager = new Class({ */ this.displayScale = new Vector2(1, 1); + /** + * The cached inverse of any CSS transforms applied to the canvas or its ancestors. + * Used to correctly convert pointer coordinates when the canvas has been CSS rotated or skewed. + * `null` if no CSS transforms are detected or DOMMatrix is unavailable. + * + * @name Phaser.Scale.ScaleManager#_cssTransformInverse + * @type {?DOMMatrix} + * @private + * @since 4.NEXT + */ + this._cssTransformInverse = null; + /** * If set, the canvas sizes will be automatically passed through Math.floor. * This results in rounded pixel display values, which is important for performance on legacy @@ -1255,6 +1267,8 @@ var ScaleManager = new Class({ bounds.y = clientRect.top + (window.pageYOffset || 0) - (document.documentElement.clientTop || 0); bounds.width = clientRect.width; bounds.height = clientRect.height; + + this._cssTransformInverse = this.getInverseCSSTransform(); }, /** @@ -1287,6 +1301,198 @@ var ScaleManager = new Class({ return (pageY - this.canvasBounds.top) * this.displayScale.y; }, + /** + * Transforms page coordinates into the scaled coordinate space of the Scale Manager, + * accounting for any CSS transforms (rotation, skew) applied to the canvas or its ancestors. + * + * Unlike the separate `transformX` and `transformY` methods, this handles coordinate + * coupling caused by CSS rotations where the X and Y axes are no longer independent. + * + * Only 2D CSS transforms are supported. 3D rotations and `perspective` cannot be + * inverted to a flat 2D mapping and will not produce correct results. + * + * @method Phaser.Scale.ScaleManager#transformXY + * @since 4.NEXT + * + * @param {number} pageX - The DOM pageX value. + * @param {number} pageY - The DOM pageY value. + * @param {Phaser.Types.Math.Vector2Like} output - An object with `x` and `y` properties to store the result. + * + * @return {Phaser.Types.Math.Vector2Like} The output object with translated `x` and `y` values. + */ + transformXY: function (pageX, pageY, output) + { + var inv = this._cssTransformInverse; + + if (inv) + { + // CSS transforms couple the X and Y axes, so we transform as a single point. + // Get the center of the canvas AABB in page coordinates. + var bounds = this.canvasBounds; + var centerX = bounds.x + bounds.width / 2; + var centerY = bounds.y + bounds.height / 2; + + // Offset from canvas center in screen space + var dx = pageX - centerX; + var dy = pageY - centerY; + + // Apply inverse rotation/scale to get canvas-local offset from center + var lx = inv.a * dx + inv.c * dy; + var ly = inv.b * dx + inv.d * dy; + + // Convert from center-relative to top-left-relative canvas-local coordinates, + // then scale to game coordinates using the canvas CSS dimensions + var canvasW = this.canvas.clientWidth || this.canvas.width; + var canvasH = this.canvas.clientHeight || this.canvas.height; + + output.x = (lx + canvasW / 2) * (this.baseSize.width / canvasW); + output.y = (ly + canvasH / 2) * (this.baseSize.height / canvasH); + } + else + { + // No CSS transforms, use the simple offset + scale path + output.x = (pageX - this.canvasBounds.left) * this.displayScale.x; + output.y = (pageY - this.canvasBounds.top) * this.displayScale.y; + } + + return output; + }, + + /** + * Computes the inverse of the accumulated CSS transform matrix applied to the canvas + * and its ancestor elements. Returns `null` if no CSS transforms are present or if + * `DOMMatrix` is not available. + * + * This walks up the DOM tree from the canvas element, composing any CSS `transform` + * values found along the way, as well as the individual `rotate` and `scale` + * properties, then extracts the linear (rotation/scale/skew) portion and returns + * its inverse. Translation, including the `translate` property, is excluded because + * canvas positioning is already handled by `getBoundingClientRect()`. + * + * Only 2D transforms are supported: 3D rotations and `perspective` cannot be + * inverted to a flat 2D mapping. + * + * @method Phaser.Scale.ScaleManager#getInverseCSSTransform + * @since 4.NEXT + * + * @return {?DOMMatrix} The inverse CSS transform matrix, or `null` if none is needed. + */ + getInverseCSSTransform: function () + { + if (typeof DOMMatrix === 'undefined') + { + return null; + } + + var hasTransform = false; + var matrix = new DOMMatrix(); + var el = this.canvas; + + while (el && el instanceof HTMLElement) + { + var local = this.getElementCSSMatrix(el); + + if (!local.isIdentity) + { + // Pre-multiply: ancestor transforms apply on the outside + matrix = local.multiply(matrix); + hasTransform = true; + } + + el = el.parentElement; + } + + if (!hasTransform || matrix.isIdentity) + { + return null; + } + + // Zero the translation since canvas position is already handled by canvasBounds + matrix.e = 0; + matrix.f = 0; + + var inverse = matrix.inverse(); + + // A degenerate transform (e.g. `scale(0)`) cannot be inverted and yields NaNs + if (!isFinite(inverse.a + inverse.b + inverse.c + inverse.d)) + { + return null; + } + + return inverse; + }, + + /** + * Builds the CSS transformation matrix for a single element, composing the + * individual `rotate` and `scale` properties with the `transform` property in + * the order defined by the CSS Transforms Level 2 specification (rotate, then + * scale, then transform). + * + * The `translate` property is intentionally ignored: only the linear part of + * the composed matrix is used by `getInverseCSSTransform`, and translation + * anywhere in the chain never affects the linear part. + * + * @method Phaser.Scale.ScaleManager#getElementCSSMatrix + * @since 4.NEXT + * + * @param {HTMLElement} el - The element to read the computed transform styles from. + * + * @return {DOMMatrix} The composed transformation matrix for the element. + */ + getElementCSSMatrix: function (el) + { + var style = window.getComputedStyle(el); + + var transform = style.transform; + var matrix = (transform && transform !== 'none') ? new DOMMatrix(transform) : new DOMMatrix(); + + var scale = style.scale; + + if (scale && scale !== 'none') + { + var s = scale.split(' '); + var sx = parseFloat(s[0]); + var sy = (s.length > 1) ? parseFloat(s[1]) : sx; + var sz = (s.length > 2) ? parseFloat(s[2]) : 1; + + matrix = new DOMMatrix().scaleSelf(sx, sy, sz).multiply(matrix); + } + + var rotate = style.rotate; + + if (rotate && rotate !== 'none') + { + var r = rotate.split(' '); + var angle = parseFloat(r[r.length - 1]); + var rm = new DOMMatrix(); + + if (r.length === 1) + { + rm.rotateSelf(angle); + } + else if (r[0] === 'x') + { + rm.rotateAxisAngleSelf(1, 0, 0, angle); + } + else if (r[0] === 'y') + { + rm.rotateAxisAngleSelf(0, 1, 0, angle); + } + else if (r[0] === 'z') + { + rm.rotateAxisAngleSelf(0, 0, 1, angle); + } + else + { + rm.rotateAxisAngleSelf(parseFloat(r[0]), parseFloat(r[1]), parseFloat(r[2]), angle); + } + + matrix = rm.multiply(matrix); + } + + return matrix; + }, + /** * Sends a request to the browser to ask it to go in to full screen mode, using the {@link https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API Fullscreen API}. * diff --git a/tests/scale/ScaleManager.test.js b/tests/scale/ScaleManager.test.js new file mode 100644 index 0000000000..58e22b7a51 --- /dev/null +++ b/tests/scale/ScaleManager.test.js @@ -0,0 +1,107 @@ +var ScaleManager = require('../../src/scale/ScaleManager'); + +describe('Phaser.Scale.ScaleManager', function () +{ + describe('transformXY', function () + { + var output; + + beforeEach(function () + { + output = { x: 0, y: 0 }; + }); + + it('should match the transformX/transformY math when no CSS transform is present', function () + { + var manager = { + _cssTransformInverse: null, + canvasBounds: { left: 10, top: 20 }, + displayScale: { x: 2, y: 0.5 } + }; + + ScaleManager.prototype.transformXY.call(manager, 110, 120, output); + + expect(output.x).toBe(ScaleManager.prototype.transformX.call(manager, 110)); + expect(output.y).toBe(ScaleManager.prototype.transformY.call(manager, 120)); + expect(output.x).toBe(200); + expect(output.y).toBe(50); + }); + + it('should invert a 90 degree CSS rotation', function () + { + // An 800x600 canvas rotated 90deg has a 600x800 AABB. + // The cached inverse is a -90deg rotation: [ 0 1 ] + // [ -1 0 ] + var manager = { + _cssTransformInverse: { a: 0, b: -1, c: 1, d: 0 }, + canvasBounds: { x: 0, y: 0, width: 600, height: 800 }, + canvas: { clientWidth: 800, clientHeight: 600 }, + baseSize: { width: 800, height: 600 } + }; + + // Canvas-local (0, 0) lands at page (600, 0) after rotation + ScaleManager.prototype.transformXY.call(manager, 600, 0, output); + expect(output.x).toBeCloseTo(0); + expect(output.y).toBeCloseTo(0); + + // Canvas-local (800, 600) lands at page (0, 800) + ScaleManager.prototype.transformXY.call(manager, 0, 800, output); + expect(output.x).toBeCloseTo(800); + expect(output.y).toBeCloseTo(600); + + // The center is rotation-invariant + ScaleManager.prototype.transformXY.call(manager, 300, 400, output); + expect(output.x).toBeCloseTo(400); + expect(output.y).toBeCloseTo(300); + }); + + it('should invert a CSS scale and map to game coordinates', function () + { + // A 400x300 canvas scaled 2x via CSS has an 800x600 AABB at (100, 50). + // The game resolution (baseSize) is 800x600. + var manager = { + _cssTransformInverse: { a: 0.5, b: 0, c: 0, d: 0.5 }, + canvasBounds: { x: 100, y: 50, width: 800, height: 600 }, + canvas: { clientWidth: 400, clientHeight: 300 }, + baseSize: { width: 800, height: 600 } + }; + + ScaleManager.prototype.transformXY.call(manager, 100, 50, output); + expect(output.x).toBeCloseTo(0); + expect(output.y).toBeCloseTo(0); + + ScaleManager.prototype.transformXY.call(manager, 500, 350, output); + expect(output.x).toBeCloseTo(400); + expect(output.y).toBeCloseTo(300); + + ScaleManager.prototype.transformXY.call(manager, 900, 650, output); + expect(output.x).toBeCloseTo(800); + expect(output.y).toBeCloseTo(600); + }); + + it('should return the output object', function () + { + var manager = { + _cssTransformInverse: null, + canvasBounds: { left: 0, top: 0 }, + displayScale: { x: 1, y: 1 } + }; + + var result = ScaleManager.prototype.transformXY.call(manager, 5, 5, output); + + expect(result).toBe(output); + }); + }); + + describe('getInverseCSSTransform', function () + { + it('should return null when DOMMatrix is unavailable or no transforms exist', function () + { + // jsdom does not implement DOMMatrix, and even if it did, this + // detached canvas has no CSS transforms anywhere in its chain. + var manager = { canvas: document.createElement('canvas') }; + + expect(ScaleManager.prototype.getInverseCSSTransform.call(manager)).toBeNull(); + }); + }); +});