/** Image class for use in gameify. Usually you'll access this through the gameify object.
* @example // Use images via gameify
* // This is the most common way
* import { gameify } from "./gameify/gameify.js"
* let myImage = new gameify.Image("player.png");
* @example // Import just images
* import { images } from "./gameify/image.js"
* let myImage = new images.Image("player.png");
* @global
*/
export let images = {
/** An image for use in sprites and other places.
* @alias gameify.Image
* @example let playerImage = new gameify.Image("images/player.png");
* @arg {String} [path] - The image filepath. (Can also be a dataURI). If not specified, the image is created with no texture
*/
Image: class {
constructor(path) {
this.path = path || "";
if (path !== undefined) {
this.texture = document.createElement("img");
this.texture.src = path;
let pathName = path;
if (path.length > 50) {
pathName = path.slice(0, 40) + '...';
}
this.texture.onerror = () => {
throw new Error(`Your image "${pathName}" couldn't be loaded. Check the path, and make sure you don't have any typos.`);
}
this.texture.onload = () => {
console.info(`Loaded image "${pathName}"`)
this.loaded = true;
// don't reset the crop if it was already specified.
if (!this.cropData.width) this.cropData.width = this.texture.width;
if (!this.cropData.height) this.cropData.height = this.texture.height;
if (this.#loadFunction) { this.#loadFunction(); }
}
}
}
/** The image filepath. Modifying this will not do anything.
* @readonly
*/
path;
/** The opaciy of the image
* @type {Number}
* @default 1
*/
opacity = 1;
/** If the image is loaded
* @type {Boolean}
*/
loaded = false;
#loadFunction = undefined;
/** If the image was derived from a tileset, details about where it came from.
* Mostly used for serialization. See example below for schema.
* @example
* { // tileData schema:
* tileset: Tileset,
* position: { x: Number, y: Number }
* size: { x: Number, y: Number }
* collisionShape: gameify.shapes.Shape OR undefined,
* tags: [ String ]
* }
* @type {Object}
*/
tileData = {};
cropData = { x: 0, y: 0, width: 0, height: 0, cropped: false };
texture = undefined;
/** Creates a object from JSON data
* @method
* @arg {Object|Array} data - Serialized object data (from object.toJSON)
* @arg {Function} ref - A function that returns a name for other objects, so they can be restored later
* @returns {gameify.Image}
*/
static fromJSON = (data, find) => {
if (Array.isArray(data)) {
// Be backwards compatible
console.warn('Save is using the old (de)serialization format for Image.');
const obj = new images.Image(data[0]);
if (data[1]) obj.cropData = data[1];
return obj;
}
if (data.tileData) {
const tileset = find(data.tileData.tileset);
// Don't apply crop data, getTile sets the crop, and in most cases
// you would want the image to reflect changes to the tileset,
// not the specific crop it was originally created with.
const pos = data.tileData.position;
const size = data.tileData.size || { x: 1, y: 1 };
return tileset.getTile(pos.x, pos.y, size.x, size.y);
} else {
return new images.Image(data.path, data.cropData);
}
}
/** Convert the object to JSON
* @method
* @arg {string} [key] - Key object is stored under (unused, here for consistency with e.g. Date.toJSON, etc.)
* @arg {function} ref - A function that returns a name for other objects, so they can be restored later
* @returns {Object}
*/
toJSON = (key, ref) => {
let tileData = undefined;
if (this.tileData.tileset) {
tileData = {
tileset: ref(this.tileData.tileset),
position: this.tileData.position,
size: this.tileData.size
// We don't need to save the collision shape,
// It's saved as part of the tileset
}
}
return {
path: this.path,
cropData: this.getCrop(),
tileData: tileData
};
}
/** Change and load a new image path. Reset's the image's crop
* @method
* @param {string} path - The new image path
*/
changePath = (path) => {
this.path = path;
const ni = new images.Image(path);
ni.onLoad(() => {
this.texture = ni.texture;
this.cropData.width = this.texture.width;
this.cropData.height = this.texture.height;
if (this.#loadFunction) { this.#loadFunction(); }
});
}
/** Set a function to be run when the image is loaded
* @method
* @param {function} callback - The function to be called when the image is loaded.
*/
onLoad = (callback) => { this.loadFunction = callback; }
/** Crop the image
* @method
* @param {Number} x - how much to crop of the left of the image
* @param {Number} y - how much to crop of the right of the image
* @param {Number} width - how wide the resulting image should be
* @param {Number} height - how tall the resulting image should be
*/
crop = (x, y, width, height) => {
if (x === undefined || y === undefined || width === undefined || height === undefined) {
throw new Error("x, y, width and height must be specified");
}
this.cropData = { x: x, y: y, width: width, height: height, cropped: true };
}
/** Remove crop from the image
* @method
*/
uncrop = () => {
this.cropData.cropped = false;
}
/** Get the image crop. Returns an object with x, y, width, and height properties.
* @method
*/
getCrop = () => {
return JSON.parse(JSON.stringify(this.cropData));
}
/** Draw the image to a context
* @method
* @param {CanvasRenderingContext2D} context - The canvas context to draw to
* @param {Number} x - The x coordinate to draw at
* @param {Number} y - The y coordinate to draw at
* @param {Number} w - Width
* @param {Number} h - Height
* @param {Number} r - Rotation, in degrees
*/
draw = (context, x, y, w, h, r, ignoreOpacity=false) => {
const originalOpacity = context.globalAlpha;
if (!ignoreOpacity) context.globalAlpha = this.opacity;
if (r) {
// translate the canvas to draw rotated images
const transX = x + w / 2;
const transY = y + h / 2;
const transAngle = (r * Math.PI) / 180; // convert degrees to radians
context.translate(transX, transY);
context.rotate(transAngle);
if (this.cropData.cropped) {
context.drawImage( this.texture,
// source coordinates
this.cropData.x,
this.cropData.y,
this.cropData.width,
this.cropData.height,
// destination coordinates
-w / 2,
-h / 2,
w,
h );
} else {
context.drawImage( this.texture,
// omit source coordinates when not cropping
-w / 2,
-h / 2,
w,
h );
}
context.rotate(-transAngle);
context.translate(-transX, -transY);
} else {
if (this.cropData.cropped) {
context.drawImage( this.texture,
// source coordinates
this.cropData.x,
this.cropData.y,
this.cropData.width,
this.cropData.height,
// destination
x, y, w, h );
} else {
context.drawImage( this.texture,
// omit source coordinates when not cropping
x, y, w, h );
}
}
// reset the alpha
if (!ignoreOpacity) context.globalAlpha = originalOpacity;
}
},
}