Over-engineering rectangles
📅
I have been kicking around an idea for a while that would require some simple collision detection logic in JavaScript. How simple? Well, the only requirement would be that I could be able to tell when two rectangles are intersecting.
Rectangle collision detection, the simple way
Any rectangle can be described by the coordinates of its opposing corners. For example:
const rectangle1 = { minX: 10, minY: 10, maxX: 30, maxY: 30 };
const rectangle2 = { minX: 20, minY: 20, maxX: 40, maxY: 40 };
Finding out if two rectangles overlap is straightforward:
/**
* Determine if two rectangles, defined by the coordinates of their opposing
* corners, overlap.
* @param {Object} rectangle1
* @param {Object} rectangle2
* @return {Boolean}
*/
function areColliding( rectangle1, rectangle2 ) {
return (
( rectangle1.minX <= rectangle2.maxX )
&& ( rectangle1.maxX >= rectangle2.minX )
&& ( rectangle1.minY <= rectangle2.maxY )
&& ( rectangle1.maxY >= rectangle2.minY )
);
}
Great! Mission accomplished, time to wrap up.
Why would I want anything else?
There are quite a few plausible and somewhat reasonable answers to the previous question. There are more things to do with rectangles, more often than not. Also, maintaining (or even browsing) an ever-growing collection of snippets to copy-paste when required may not be the best way to manage a codebase.
The answer, in my case, was “because”. The use I intend to make of whatever comes out of this will not justify its complexity. I would not be surprised if it less performant than copying and pasting the previous logic where required. Nevertheless, structuring the logic in a conventional package is useful for me, even if it is just to provide a lexical context to the code that I may end up copying and pasting.
Enter the Box
class.
What’s in the Box
‽
Box
is a shorter name than “Rectangle” for a class that represents just that, a rectangle as per the previous definition. Basically:
/**
* A representation of a rectangle.
* @class
*/
class Box {
/** @type {Number} */
minX = 0;
/** @type {Number} */
maxX = 0;
/** @type {Number} */
minY = 0;
/** @type {Number} */
maxY = 0;
/**
* Determine if two `Box` instances are colliding.
* @param {Box} box1
* @param {Box} box2
* @return {Boolean}
*/
static areColliding( box1, box2 ) {
return (
( box1.minX <= box2.maxX )
&& ( box1.maxX >= box2.minX )
&& ( box1.minY <= box2.maxY )
&& ( box1.maxY >= box2.minY )
);
}
/**
* Determine if the `Box` instance is colliding with `anotherBox`.
* @param {Box} anotherBox
* @return {Boolean}
*/
isColliding( anotherBox ) { return Box.areColliding( this, anotherBox ); }
/**
* Create a `Box` rectangle, defined by the coordinates of its opposing corners.
* @param {Number} minX
* @param {Number} maxX
* @param {Number} minY
* @param {Number} maxY
* @example <caption>Create a 10×10 `Box` at (25,30)</caption>
* const box = new Box( 25, 30, 35, 40 );
*/
constructor( minX, minY, maxX, maxY ) {
this.minX = minX;
this.maxX = maxX;
this.minY = minY;
this.maxY = maxY;
}
}
There you go, all nicely OOPed, with the collision detection logic in static and dynamic form.
Great! Mission accomplished, time to wrap up.
Where am I going to use this?
Nowhere, really, in this form.
The idea I have been noodling with requires the rectangles to be painted in a CanvasRenderingContext2D
. In this context, rectangles are defined by its top-left corner coordinates, x
and y
, and its size, width
and height
.
Reworking the Box
class with this in mind is trivial, substituting minX
with x
, minY
with y
, maxX
with x + width
, and maxY
with y + height
.
Great! Mission accomplished, time to wrap up.
Complicating things, the OOP way
What if I decided arbitrarily that I wanted to keep addition and substraction out of the collision detection logic? Well, for starters, I would have to keep the min_
and max_
variables. I would also have to implement the logic to maintain the coherence of the different properties.
And that is precisely what I am going to do.
Inside the class Box {}
definition, I establish a set of private variables.
/** @type {Number} */
#minX = 0;
/** @type {Number} */
#maxX = 0;
/** @type {Number} */
#minY = 0;
/** @type {Number} */
#maxY = 0;
/** @type {Number} */
#width = 0;
/** @type {Number} */
#height = 0;
Then, I define the getters and setters for the class’ original properties:
/** @type {Number} */
get minX() { return this.#minX; }
set minX( value ) {
if ( isNaN( value ) ) { return; }
this.#minX = value;
this.#maxX = value + this.#width;
}
/** @type {Number} */
get maxX() { return this.#maxX; }
set maxX( value ) {
if ( isNaN( value ) ) { return; }
this.#maxX = value;
this.#minX = value - this.#width;
}
/** @type {Number} */
get minY() { return this.#minY; }
set minY( value ) {
if ( isNaN( value ) ) { return; }
this.#minY = value;
this.#maxY = value + this.#height;
}
/** @type {Number} */
get maxY() { return this.#maxY; }
set maxY( value ) {
if ( isNaN( value ) ) { return; }
this.#maxY = value;
this.#minY = value - this.#height;
}
After that, I add do the same thing for the properties with the CanvasRenderingContext2D
rectangle nomenclature:
/** @type {Number} */
get x() { return this.#minX; }
set x( value ) { this.minX = value; }
/** @type {Number} */
get y() { return this.#minY; }
set y( value ) { this.minY = value; }
/** @type {Number} */
get width() { return this.#width; }
set width( value ) {
if ( isNaN( value ) || ( value < 0 ) ) { return; }
this.#width = value;
this.#maxX = this.#minX + value; // This is a choice, not self-evident.
}
/** @type {Number} */
get height() { return this.#height; }
set height( value ) {
if ( isNaN( value ) || ( value < 0 ) ) { return; }
this.#height = value;
this.#maxY = this.#minY + value; // This is a choice, not self-evident.
}
After copying the areColliding
and isColliding
methods, the only thing left to do is update the constructor:
/**
* Create a `Box` rectangle, defined by the coordinates of a corner and its size.
* @param {Number} [x=0]
* @param {Number} [y=0]
* @param {Number} [width=0] Cannot be a negative number.
* @param {Number} [height=0] Cannot be a negative number.
*/
constructor( x = 0, y = 0, width = 0, height = 0 ) {
this.#minX = x;
this.#minY = y;
this.width = width; // This will also set `this.#maxX` value
this.height = height; // This will also set `this.#maxY` value
}
Great! Mission accomplished, time to wrap up.
More is less
That is quite a bit more code for, basically, the same functionality. Granted, now I prevent some erroneous handling of the object’s properties (e.g., setting a minX
that is more than maxX
) and, for a CanvasRenderingContext2D
object named context
, instead of
context.fillRect( box.minX, box.minY, box.maxX - box.minX, box.maxY - box.minY );
, I can declare
context.fillRect( box.x, box.y, box.width, box.height );
, but there are no substantial gains to be found here. Furthermore, as I stated before, I would expect the new Box
class to be less performant than the original, although I would have to test it.
In for a penny, in for a pound, so, there are quite a few ways to overload this class. For example, another property could be
/**
* @readonly
* @type {Number[]} An array with the values for `x`, `y`, `width` and `height`.
*/
get rect() { return [ this.#minX, this.#minY, this.#width, this.#height ]; }
so, again, for a CanvasRenderingContext2D
object named context
:
context.fillRect( ...box.rect );
It does look prettier or, at least, more compact, although I would (and did) argue against some uses of the spread operator.
Great! Mission accomplished, time to wrap up.
There and back again
Having looked ahead, I can anticipate having to convert to and from the format of the data I will be handling. Why not add that, too?
/**
* @readonly
* @type {Object} An object with `x`, `y`, `width` and `height` properties.
*/
get rectangle() { return {
x: this.#minX, y: this.#minY, width: this.#width, height: this.#height
}; }
/**
* Instances a `Box` from a `rect` array.
* @param {Number[]} rect
* An array with the values for `x`, `y`, `width` and `height`.
* @return {(Box|Number[])?}
*/
static createFromArray( rect ) {
if ( !(
( rect instanceof Array )
&& ( rect.length === 4 )
&& !isNaN( rect[ 0 ] ) && !isNaN( rect[ 1 ] )
&& !isNaN( rect[ 2 ] ) && !isNaN( rect[ 3 ] )
&& ( rect[ 2 ] >= 0 )
&& ( rect[ 3 ] >= 0 )
) ) {
return rect;
}
return ( new Box ( rect[ 0 ], rect[ 1 ], rect[ 2 ], rect[ 3 ] ) );
}
/**
* Instances a `Box` from a `rectangle` object.
* @param {Object} rectangle
* An object with `x`, `y`, `width` and `height` properties.
* @return {(Box|Object)?}
*/
static createFromObject( rectangle ) {
if ( !(
( rectangle instanceof Object )
&& rectangle.hasOwnProperty( "x" ) && !isNaN( rectangle.x )
&& rectangle.hasOwnProperty( "y" ) && !isNaN( rectangle.y )
&& rectangle.hasOwnProperty( "width" ) && !isNaN( rectangle.width )
&& rectangle.hasOwnProperty( "height" ) && !isNaN( rectangle.height )
&& ( rectangle.width >= 0 )
&& ( rectangle.height >= 0 )
) ) {
return rectangle;
}
return (
new Box ( rectangle.x, rectangle.y, rectangle.width, rectangle.height )
);
}
/**
* Instances a `Box` from a `rect` array or a `rectangle` object.
* @param {Number[]|Object} source
* @return {(Box|Object)?}
*/
static createFrom( source ) {
if ( !( source instanceof Object ) ) { return source; }
if ( source instanceof Array ) { return Box.createFromArray( source ); }
return Box.createFromObject( source );
}
Great! Mission accomplished, time to wrap up.
To be continued
By this stage, I should have started writting tests, establishing metrics and measuring. Ideally, I would have started when I defined the areColliding
method.
I am not going to do it. (Shame! Shame! Shame!)
I will be able to test its functionality and performance if (hopefully, when) I start developing my idea. In the interim, maybe I can write a bit more.