import { vectors } from "./vector.js"
"use strict"
/** A generic shape. The base for all other shapes.
* @constructor
* @arg {string} type - The shape type
* @arg {number} [x=0] - The x position
* @arg {number} [y=0] - The y position
* @alias gameify.shapes.Shape
*/
class Shape {
constructor(type, x = 0, y = 0) {
this.#type = type || "Shape";
if (typeof x !== "number" || typeof y !== "number") {
throw "Shape x and y radius must be numbers";
}
this.position = new vectors.Vector2d(x, y);
}
/** Creates a object from JSON data
* @method
* @arg {Object|Array} data - Serialized object data (from object.toJSON)
* @returns {gameify.Tileset}
*/
static fromJSON = (data) => {
let newShape;
switch (data.type) {
case "Shape":
newShape = new Shape(data.type, data.position.x, data.position.y); break;
case "Circle":
newShape = new Circle(data.position.x, data.position.y, data.radius); break;
case "Rectangle":
newShape = new Rectangle(data.position.x, data.position.y, data.size.x, data.size.y); break;
case "Polygon":
newShape = new Polygon(data.position.x, data.position.y, data.points); break;
default:
throw new Error("Unknown shape type: " + data.type);
}
newShape.strokeColor = data.strokeColor;
newShape.fillColor = data.fillColor;
return newShape;
}
/** Create a copy of the shape */
copy = () => {
// Use inherited toJSON function if available
let toJSON = this.toJSON;
if (typeof toJSON !== "function") {
console.warn("Shape.toJSON is not available! Why are you using the base Shape? using __toJSON instead.");
toJSON = this.__toJSON;
}
return Shape.fromJSON(toJSON());
}
/** Convert the object to JSON. Not available on the base Shape, only on inherited classes
* @method
* @alias gameify.shapes.Shape#toJSON
*/
__toJSON = (key) => {
return {
type: this.#type,
position: this.position.toJSON(),
strokeColor: this.strokeColor,
fillColor: this.fillColor
}
}
#type;
/** A string represeting the type of shape, eg "Circle"
* @type {String}
* @readonly
* @name gameify.shapes.Shape#type
*/
get type() { return this.#type; }
/** The position of the shape, often locked to the position of a sprite that it represents
* @type {gameify.Vector2d}
*/
position = new vectors.Vector2d(0, 0);
/** The stroke color when this shape is drawn
* @type {String}
*/
strokeColor = "#25ac";
/** The fill color when this shape is drawn
* @type {String}
*/
fillColor = "#58c3";
/** Check if this shape collides with another shape
* @virtual
* @method
* @arg {shapes.Shape} obj - The object to check for collision
* @arg {Boolean} [recursion=false] - If this is a recursive call
* @return {Boolean}
*/
collidesWith = (obj, recursion) => {
throw new Error("shape.collidesWith is not available in the base Shape class. It must be implemented by each specific shape type");
}
/** Check if a point (Vector2d) is inside this shape
* @virtual
* @method
* @param {gameify.Vector2d} point - The point to check
* @return {Boolean}
*/
contains = (point) => {
throw new Error("shape.contains is not available in the base Shape class. It must be implemented by each specific shape type");
}
/** Draw a hitbox for debugging
* @virtual
* @method
* @param {CanvasRenderingContext2D} context - The rendering context to draw to
*/
draw = (canvas) => {
throw new Error("shape.draw is not available in the base Shape class. It must be implemented by each specific shape type");
}
/** Create a copy of this shape, scaled by a factor.
* @method
* @arg {Number} scale - The scale factor
* @returns {gameify.shapes.Shape}
*/
scaled(scale) {
const newShape = this.copy();
newShape.position = newShape.position.multiply(scale);
return newShape;
}
/** Create a copy of this shape, rotated around a point, in radians (counterclockwise)
* @method
* @arg {Number} angle - The angle to rotate by, in radians
* @arg {gameify.Vector2d} [point=<0, 0>] - The point to rotate around
* @returns {gameify.shapes.Shape}
*/
rotated(angle, point=new vectors.Vector2d(0, 0)) {
const newShape = this.copy();
newShape.position = newShape.position.rotatedAbout(angle, point);
return newShape;
}
}
/** A circle shape
* @constructor
* @alias gameify.shapes.Circle
* @extends gameify.shapes.Shape
* @param {number} x - The x position
* @param {number} y - The y position
* @param {number} radius - The circle radius
*/
class Circle extends Shape {
constructor (x, y, radius){
super("Circle", x, y);
if (typeof radius !== "number") {
throw "Circle radius must be a number";
}
this.radius = radius;
}
/** Convert the object to JSON
* @method
*/
toJSON = (key) => {
const data = this.__toJSON(key);
data.radius = this.radius;
return data;
}
#radius;
/** The radius of the circle
* @type {Number}
* @name gameify.shapes.Circle#radius
*/
get radius() { return this.#radius; }
set radius(value) {
if (typeof value !== "number") {
throw "Circle radius must be a number";
}
this.#radius = value;
}
/** Check if this shape collides with another shape
* @method
* @arg {shapes.Shape} obj - The object to check for collision
* @arg {Boolean} [recursion=false] - If this is a recursive call
* @return {Boolean}
*/
collidesWith = (obj, recursion) => {
if (obj.type === "Circle") {
return ( this.position.subtract(obj.position).getMagnitude() < this.radius + obj.radius );
} else {
// don't create an infinite recursion loop if neither object has a collision function
if (recursion) {
throw new Error("Collision between these shapes is not supported.");
}
// else see if the passed object can handle the request
return obj.collidesWith(this, /*recursion=*/true);
}
}
/** Check if a point (Vector2d) is inside this shape
* @param {gameify.Vector2d} point - The point to check
* @method
* @returns {Boolean}
*/
contains = (point) => {
vectors.Vector2d.assertIsCompatibleVector(point);
return this.position.distanceTo(point) < this.radius;
}
/** Draw a hitbox for debugging
* @method
* @param {CanvasRenderingContext2D} context - The rendering context to draw to
*/
draw = (context) => {
context.strokeStyle = this.strokeColor;
context.fillStyle = this.fillColor;
context.beginPath();
context.arc( this.position.x,
this.position.y,
this.radius, 0, 2 * Math.PI );
context.stroke();
context.fill();
}
/** Create a copy of this shape, scaled by a factor
* @method
* @arg {Number} scale - The scale factor
* @returns {gameify.shapes.Circle}
*/
scaled = (scale) => {
const newShape = super.scaled(scale);
newShape.radius *= scale;
return newShape;
}
/** Create a copy of this shape, rotated around a point, in radians (counterclockwise)
* @method
* @arg {Number} angle - The angle to rotate by, in radians
* @arg {gameify.Vector2d} [point=<0, 0>] - The point to rotate around
* @returns {gameify.shapes.Circle}
*/
rotated(angle, point) {
// Circles are round, no need to move anything but the centerpoint
return super.rotated(angle, point);
}
}
/** A rectangle shape
* @constructor
* @alias gameify.shapes.Rectangle
* @extends gameify.shapes.Shape
* @param {number} x - The x position
* @param {number} y - The y position
* @param {number} width - The rectangle width
* @param {number} height - The rectangle height
*/
class Rectangle extends Shape {
constructor (x, y, width, height) {
super("Rectangle", x, y);
if (typeof width !== "number" || typeof height !== "number") {
throw "Rectangle width and height must be numbers";
}
if (width < 0 || height < 0) {
width = Math.abs(width);
height = Math.abs(height);
console.warn('Rectangle width and height should be >= 0. Using absolute values instead.');
}
this.size = new vectors.Vector2d(width, height);
}
/** Convert the object to JSON
* @method
*/
toJSON = (key) => {
const data = this.__toJSON(key);
data.size = this.#size.toJSON();
return data;
}
#size;
/** The size (width and height) of the rectangle
* @type {gameify.Vector2d}
* @name gameify.shapes.Rectangle#size
*/
get size() { return this.#size; }
set size(value) {
this.#size = new vectors.Vector2d(value);
}
/** The width of the rectangle (alias of Rectangle.size.x)
* @type {Number}
* @name gameify.shapes.Rectangle#width
*/
get width () { return this.size.x; }
set width(value) {
this.size.x = value;
}
/** The height of the rectangle (alias of Rectangle.size.y)
* @type {Number}
* @name gameify.shapes.Rectangle#height
*/
get height () { return this.size.y; }
set height(value) {
this.size.y = value;
}
/** Check if this shape collides with another shape
* @method
* @arg {shapes.Shape} obj - The object to check for collision
* @arg {Boolean} [recursion=false] - If this is a recursive call
* @return {Boolean}
*/
collidesWith = (obj, recursion) => {
if (obj.type === "Rectangle") {
if (this.position.x < obj.position.x + obj.size.x && this.position.x + this.size.x > obj.position.x
&& this.position.y < obj.position.y + obj.size.y && this.position.y + this.size.y > obj.position.y
) return true;
// else no collision
return false;
} else if (obj.type === "Circle") {
const topRight = new vectors.Vector2d(this.position);
topRight.x += this.size.x;
const bottomLeft = new vectors.Vector2d(this.position);
bottomLeft.y += this.size.y;
const bottomRight = new vectors.Vector2d(this.position).add(this.size);
if (obj.position.distanceTo(this.position, topRight) < obj.radius
|| obj.position.distanceTo(this.position, bottomLeft) < obj.radius
|| obj.position.distanceTo(topRight, bottomRight) < obj.radius
|| obj.position.distanceTo(bottomLeft, bottomRight) < obj.radius
|| (obj.position.x > this.position.x && obj.position.y > this.position.y
&& obj.position.x < bottomRight.x && obj.position.y < bottomRight.y)
) {
return true;
}
return false;
} else {
// don't create an infinite recursion loop if neither object has a collision function
if (recursion) {
throw new Error("Collision between these shapes is not supported.");
}
// else see if the passed object can handle the request
return obj.collidesWith(this, /*recursion=*/true);
}
}
/** Check if a point (Vector2d) is inside this shape
* @param {gameify.Vector2d} point - The point to check
* @method
* @returns {Boolean}
*/
contains = (point) => {
vectors.Vector2d.assertIsCompatibleVector(point);
if (this.position.x < point.x && this.position.x + this.size.x > point.x
&& this.position.y < point.y && this.position.y + this.size.y > point.y
) {
return true;
} else {
return false;
}
}
/** Draw a hitbox for debugging
* @method
* @param {CanvasRenderingContext2D} context - The rendering context to draw to
*/
draw = (context) => {
context.strokeStyle = this.strokeColor;
context.fillStyle = this.fillColor;
context.beginPath();
context.rect(this.position.x, this.position.y,
this.size.x, this.size.y);
context.stroke();
context.fill();
}
/** Create a copy of this shape, scaled by a factor
* @method
* @arg {Number} scale - The scale factor
* @returns {gameify.shapes.Rectangle}
*/
scaled = (scale) => {
const newShape = super.scaled(scale);
newShape.size = newShape.size.multiply(scale);
return newShape;
}
/** Create a copy of this shape, rotated around a point, in radians (counterclockwise)
* @method
* @arg {Number} angle - The angle to rotate by, in radians
* @arg {gameify.Vector2d} [point=<0, 0>] - The point to rotate around
* @returns {gameify.shapes.Polygon} Because Rectangles must be axis-aligned
*/
rotated(angle, point) {
if (angle === 0) {
return this.copy();
}
const newShape = new shapes.Polygon(this.position.x, this.position.y, [
this.position.copy(),
this.position.add(new vectors.Vector2d(this.size.x, 0)),
this.position.add(this.size),
this.position.add(new vectors.Vector2d(0, this.size.y))
]);
return newShape.rotated(angle, point);
}
}
/** A polygon shape
* @constructor
* @alias gameify.shapes.Polygon
* @extends gameify.shapes.Shape
* @param {number} x - The x position
* @param {number} y - The y position
* @param {gameify.Vector2d[]} points - The points of the polygon, relative to the position of the polygon
*/
class Polygon extends Shape {
constructor(x, y, points) {
super("Polygon", x, y);
if (!Array.isArray(points)) {
throw new Error("Points must be an array of gameify.Vector2d or compatible vector.");
}
for (const v in points) {
this.#points[v] = new vectors.Vector2d(points[v]);
}
}
/** Convert the object to JSON
* @method
*/
toJSON = (key) => {
const data = this.__toJSON(key);
data.points = this.#points;
return data;
}
#points = [];
#segments;
#segmentsUpdated = false;
/** The points of the polygon, relative to the position of the polygon
* @type {gameify.Vector2d[]}
* @name gameify.shapes.Polygon#points
*/
set points(value) {
if (!Array.isArray(value)) {
throw new Error("Points must be an array of gameify.Vector2d or compatible vector.");
}
this.#segmentsUpdated = false;
for (const v in value) {
this.points[v] = new vectors.Vector2d(value[v]);
}
}
get points() {
return new Proxy(this.#points, {
get: (target, name) => {
return target[name];
},
set: (target, name, value) => {
this.#segmentsUpdated = false;
if (name === "length") {
target.length = value;
return true;
}
target[name] = new vectors.Vector2d(value);
return target[name];
}
});
}
/**
* @typedef {Object} LineSegment
* @property {gameify.Vector2d} a - The starting point of the line segment
* @property {gameify.Vector2d} b - The ending point of the line segment
*/
/**
* The polygon, as an array of line segments, relative to the position of the polygon
* @type {LineSegment[]}
* @name gameify.shapes.Polygon#segments
* @readonly
*/
get segments() {
if (this.#segments && this.#segmentsUpdated) {
return this.#segments;
}
this.#segments = [];
for (const i in this.#points) {
const p1 = this.#points[i];
const p2 = this.#points[(parseInt(i) + 1) % this.#points.length];
this.#segments.push({
a: p1, b: p2
});
}
this.#segmentsUpdated = true;
return this.#segments;
}
#lastpoint = null;
/** Check if a point (Vector2d) is inside this shape
* @param {gameify.Vector2d} point - The point to check
* @method
* @returns {Boolean}
*/
contains = (point) => {
vectors.Vector2d.assertIsCompatibleVector(point);
this.#lastpoint = point;
let intersections = 0;
for (const seg of this.segments) {
// Subtract a weird amount so we're not likely to have similar slopes
const int = vectors.Vector2d.segmentsIntersect(
seg.a.add(this.position), seg.b.add(this.position), point, vectors.Vector2d.from(point).subtract({x: 1000, y: 1120}),
/*tolerance=*/undefined, /*collinear=*/false
);
if (int) {
intersections++;
}
}
return intersections % 2 === 1;
}
/** Check if this shape collides with another shape
* @method
* @arg {shapes.Shape} obj - The object to check for collision
* @arg {Boolean} [recursion=false] - If this is a recursive call
* @return {Boolean}
*/
collidesWith = (obj, recursion) => {
if (obj.type === "Rectangle") {
// Rectangle intersects if any lines intersect
// or if any points are inside the rectangle
for (const seg of this.segments) {
const adjPosA = seg.a.add(this.position)
const adjPosB = seg.b.add(this.position);
const intTop = vectors.Vector2d.segmentsIntersect(
adjPosA, adjPosB, obj.position, obj.position.add(obj.size.xComponent())
);
const intBottom = vectors.Vector2d.segmentsIntersect(
adjPosA, adjPosB, obj.position.add(obj.size.yComponent()), obj.position.add(obj.size)
)
const intLeft = vectors.Vector2d.segmentsIntersect(
adjPosA, adjPosB, obj.position, obj.position.add(obj.size.yComponent())
);
const intRight = vectors.Vector2d.segmentsIntersect(
adjPosA, adjPosB, obj.position.add(obj.size.xComponent()), obj.position.add(obj.size)
);
if (intTop || intBottom || intLeft || intRight) {
return true;
}
if (obj.contains(adjPosA)) {
return true;
}
}
if (this.contains(obj.position) || this.contains(obj.position.add(obj.size))
|| this.contains(obj.position.add(obj.size.xComponent())) || this.contains(obj.position.add(obj.size.yComponent()))
) {
return true;
}
return false;
} else if (obj.type === "Circle") {
// Circle intersects if its center is inside the polygon or
// if it intersects any lines of the polygon
for (const seg of this.segments) {
const adjPosA = seg.a.add(this.position)
const adjPosB = seg.b.add(this.position);
const dist = obj.position.distanceTo(adjPosA, adjPosB);
if (dist < obj.radius) {
return true;
}
}
if (this.contains (obj.position)) {
return true;
}
return false;
} else {
// don't create an infinite recursion loop if neither object has a collision function
if (recursion) {
throw new Error("Collision between these shapes is not supported.");
}
// else see if the passed object can handle the request
return obj.collidesWith(this, /*recursion=*/true);
}
}
/** Draw a hitbox for debugging
* @method
* @param {CanvasRenderingContext2D} context - The rendering context to draw to
*/
draw = (context) => {
context.strokeStyle = this.strokeColor;
context.fillStyle = this.fillColor;
context.beginPath();
for (const point of this.#points) {
context.lineTo(this.position.x + point.x, this.position.y + point.y);
}
context.closePath();
context.stroke();
context.fill();
}
/** Create a copy of this shape, scaled by a factor
* @method
* @arg {Number} scale - The scale factor
* @returns {gameify.shapes.Polygon}
*/
scaled = (scale) => {
const newShape = super.scaled(scale);
for (const point of newShape.#points) {
point.x *= scale;
point.y *= scale;
}
return newShape;
}
/** Create a copy of this shape, rotated around a point, in radians (counterclockwise)
* @method
* @arg {Number} angle - The angle to rotate by, in radians
* @arg {gameify.Vector2d} [point=<0, 0>] - The point to rotate around
* @returns {gameify.shapes.Polygon}
*/
rotated(angle, point) {
const newShape = this.copy();
newShape.position = newShape.position.rotatedAbout(angle, point);
for (const i in newShape.points) {
// points are relative to position, which we already rotated
newShape.points[i] = newShape.points[i].rotated(angle);
}
return newShape;
}
}
/** Shapes and collision detection for use in gameify. Usually you'll access this through the gameify object.
* @example // Use shapes via gameify
* // This is the most common way
* import { gameify } from "./gameify/gameify.js"
* let myCircle = new gameify.shapes.Circle(0, 0, 5);
* @example // Import just shapes
* import { shapes } from "./gameify/collision.js"
* let myCircle = new shapes.Circle(0, 0, 5);
* @global
*/
export let shapes = {
Shape, Circle, Rectangle, Polygon
};