Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/input/InputManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
206 changes: 206 additions & 0 deletions src/scale/ScaleManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
},

/**
Expand Down Expand Up @@ -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}.
*
Expand Down
107 changes: 107 additions & 0 deletions tests/scale/ScaleManager.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});