Source: modules/image.js

/**
 * Represents a drawable image, which can be a single static image,
 * an animated spritesheet, or a blank canvas for off-screen rendering.
 */
export class Jmage{
	/** @private */
	#jBBInstance;
	/** @private */
	#el; #ready;
	/** @private */
	#frame; #firstFrame; #frameCount;
	/** @private */
	#scaleFactor;
	/** @private */
	#rotation;
	/** @private */
	#handle; #localMidHandle;
	/** @private */
	#imageData;
	/** @private */
	#imageDataCache = null;
	/** @private */
	#bufferCanvas = null;
	/** @private */
	#bufferContext = null;

	/** @param {object} jbbInstance A reference to the main jBB class instance. */
	constructor(jBBInstance){
		this.#jBBInstance = jBBInstance;
		this.#el = new Image();
		this.#ready = false;

		this.#frame = { width : 0, height : 0 };
		this.#firstFrame = 1;
		this.#frameCount = 1;

		this.#scaleFactor = { x : 1.0, y : 1.0 };
		this.#rotation = 0;

		this.#handle = { x : 0, y : 0 };
		this.#localMidHandle = false;
	}

	/**
	 * @private
	 * Internal callback executed when the underlying image element has finished loading.
	 */
	onImageLoaded(){
		this.#ready = true;
		if(this.#frameCount <= 1){
			this.#frame = { width : this.#el.width, height : this.#el.height };
		}

		// If AutoMidHandle is on globally, or if MidHandle() was called for this specific
		// image before it finished loading, apply the mid handle logic now.
		if (this.#jBBInstance.isAutoMidHandle() || this.#localMidHandle) {
			this.#handle.x = this.#frame.width / 2;
			this.#handle.y = this.#frame.height / 2;
			this.#localMidHandle = true; 
		}
	}

	/**
	 * Loads a static image from a given path.
	 * @param {string} path The URL or path to the image file.
	 * @returns {Promise<Jmage>} A promise that resolves with this Jmage instance when loading is complete.
	 */
	load(path){
		return new Promise((resolve, reject) => {
			this.#el.src = path;
			this.#frameCount = 1;
			this.#el.addEventListener("load", () => {
				this.onImageLoaded();
				resolve(this);
			});
			this.#el.addEventListener("error", (e) => {
				const error = new Error(`jBB Image: Failed to load image at ${path}`);
				console.error(error, e);
				reject(error);
			});
		});
	}

	/**
	 * @private
	 * Creates a blank, drawable image.
	 * @param {number} width The width of the new image.
	 * @param {number} height The height of the new image.
	 */
	createBlank(width, height) {
		this.#frame = { width: width, height: height };
		this.#ready = true; // It's ready for drawing immediately
		this.getBufferContext(); // Ensure the buffer is created
	}

	/**
	 * Loads an image strip for animation.
	 * @param {string} path The URL or path to the spritesheet file.
	 * @param {number} width The width of a single animation frame.
	 * @param {number} height The height of a single animation frame.
	 * @param {number} first The index of the first frame to use in the animation (1-based).
	 * @param {number} count The total number of frames in the animation.
	 * @returns {Promise<Jmage>} A promise that resolves with this Jmage instance when loading is complete.
	 */
	loadAnim(path, width, height, first, count){
		return new Promise((resolve, reject) => {
			this.#el.src = path;
			this.#frame = { width : width, height : height };
			this.#firstFrame = first;
			this.#frameCount = count;
			this.#el.addEventListener("load", () => {
				this.onImageLoaded();
				resolve(this);
			});
			this.#el.addEventListener("error", (e) => {
				const error = new Error(`jBB Image: Failed to load anim image at ${path}`);
				console.error(error, e);
				reject(error);
			});
		});
	}

	/**
	 * Sets the image's drawing handle to its center.
	 * If the image is not yet loaded, this action is deferred until loading is complete.
	 */
	midHandle(){
		this.#localMidHandle = true;

		if (this.#ready) {
			this.#handle.x = this.#frame.width / 2;
			this.#handle.y = this.#frame.height / 2;
		}
	}


	/**
	 * Sets the image's drawing handle to a specific coordinate.
	 * @param {number} x The x-coordinate of the handle.
	 * @param {number} y The y-coordinate of the handle.
	 */
	setHandle(x, y){
		this.#handle.x = x;
		this.#handle.y = y;
		this.#localMidHandle = false;
	}

	/**
	 * Scales the image. A negative value will flip the image on that axis.
	 * @param {number} [x=1.0] The horizontal scale factor.
	 * @param {number} [y=1.0] The vertical scale factor.
	 */
	scale(x = 1.0, y = 1.0){
		this.#scaleFactor = { x : x, y : y };
	}

	/**
	 * Rotates the image by a given number of degrees.
	 * @param {number} value The rotation in degrees (0-360).
	 */
	rotate(value){
		this.#rotation = value;
	}

	/**
	 * @private
	 * Draws the image (or a specific frame of it) to the active drawing context.
	 */
	draw(x, y, frame = 1){
		const sourceElement = this.#bufferCanvas || this.#el;
		
		if(frame < 1) frame = 1;
		if(frame > this.#frameCount) frame = this.#frameCount;

		if(this.#ready == true){
			this.#jBBInstance.canvas.save();

			const frameIndexOnSheet = (this.#firstFrame - 1) + (frame - 1);
			let framePos = this.getFramePos(frameIndexOnSheet);

			this.#jBBInstance.canvas.translate(x, y);
			this.#jBBInstance.canvas.rotate(this.#rotation * Math.PI / 180);
			this.#jBBInstance.canvas.scale(this.#scaleFactor.x, this.#scaleFactor.y);

			let drawX = -this.#handle.x;
			let drawY = -this.#handle.y;

			this.#jBBInstance.canvas.drawImage(sourceElement, framePos.x, framePos.y, this.#frame.width, this.#frame.height, drawX, drawY, this.#frame.width, this.#frame.height);
			
			this.#jBBInstance.canvas.restore();
		}
	}

	/**
	 * @private
	 * Creates a new Jmage instance that is a copy of this one.
	 * @returns {Jmage} A new Jmage instance with the same properties.
	 */
	clone() {
		const newImage = new Jmage(this.#jBBInstance);

		// Copy essential properties. The image element itself is shared, which is typical for BlitzBasic's CopyImage.
		newImage.#el = this.#el;
		newImage.#ready = this.#ready;
		newImage.#frame = { ...this.#frame };
		newImage.#firstFrame = this.#firstFrame;
		newImage.#frameCount = this.#frameCount;
		newImage.#scaleFactor = { ...this.#scaleFactor };
		newImage.#rotation = this.#rotation;
		newImage.#handle = { ...this.#handle };
		newImage.#localMidHandle = this.#localMidHandle;

		// If the source is a created image that has been drawn on, the copy needs
		// its own buffer with the same pixel content.
		if (this.#bufferCanvas) {
			const newBufferCtx = newImage.getBufferContext(); // Creates the canvas for the new image
			if(newBufferCtx) newBufferCtx.drawImage(this.#bufferCanvas, 0, 0);
		}

		// Note: The buffer canvas and image data cache are not copied,
		// they will be generated on demand for the new instance.

		return newImage;
	}

	/**
	 * @private
	 * Calculates how many animation frames fit in a single row of the spritesheet.
	 * @returns {number}
	 */
	framesPerRow(){
		return Math.floor(this.#el.width / this.#frame.width);
	}
	
	/**
	 * @private
	 * Calculates the top-left (x,y) pixel coordinate of a given frame on the spritesheet.
	 * @param {number} frame The 0-based index of the frame.
	 * @returns {{x: number, y: number}}
	 */
	getFramePos(frame){
		return {
			x : (frame % this.framesPerRow() * this.#frame.width),
			y : (Math.floor(frame / this.framesPerRow())) * this.#frame.height
		};
	}

	/**
	 * The width of a single frame of the image.
	 * @returns {number}
	 */
	get width(){
		return this.#frame.width;
	}

	/**
	 * The height of a single frame of the image.
	 * @returns {number}
	 */
	get height(){
		return this.#frame.height;
	}

	/**
	 * The current drawing handle of the image.
	 * @returns {{x: number, y: number}}
	 */
	get handle(){
		return this.#handle;
	}

	/**
	 * The current scale factor of the image.
	 * @returns {{x: number, y: number}}
	 */
	get scaleFactor(){
		return this.#scaleFactor;
	}

	/**
	 * The current rotation of the image in degrees.
	 * @returns {number}
	 */
	get rotation(){
		return this.#rotation;
	}

	/**
	 * The underlying drawable element (either an HTMLImageElement or an off-screen HTMLCanvasElement).
	 * @returns {HTMLImageElement|HTMLCanvasElement}
	 */
	get el(){
		return this.#bufferCanvas || this.#el;
	}

	/**
	 * @private
	 * Returns the raw ImageData object for this image.
	 * The data is cached after the first call for performance.
	 * @returns {ImageData|null}
	 */
	getImageData() {
		if (this.#imageDataCache) {
			return this.#imageDataCache;
		}
		if (!this.#ready) {
			return null;
		}
		const tempCanvas = document.createElement('canvas');
		tempCanvas.width = this.#el.width;
		tempCanvas.height = this.#el.height;
		const tempCtx = tempCanvas.getContext('2d');
		tempCtx.drawImage(this.#el, 0, 0);
		this.#imageDataCache = tempCtx.getImageData(0, 0, this.#el.width, this.#el.height);
		return this.#imageDataCache;
	}

	/**
	 * @private
	 * Creates (if necessary) and returns a 2D context for drawing onto this image.
	 * @returns {CanvasRenderingContext2D|null}
	 */
	getBufferContext() {
		if (!this.#bufferContext) {
			if (!this.#ready) return null; // Can't create buffer for an unloaded image
			this.#bufferCanvas = document.createElement('canvas');
			if (this.#el.width > 0 && this.#el.height > 0) {
				this.#bufferCanvas.width = this.#el.width;
				this.#bufferCanvas.height = this.#el.height;
			} else {
				this.#bufferCanvas.width = this.#frame.width;
				this.#bufferCanvas.height = this.#frame.height;
			}
			this.#bufferContext = this.#bufferCanvas.getContext('2d');
			if (this.#el.width > 0) this.#bufferContext.drawImage(this.#el, 0, 0);
		}
		return this.#bufferContext;
	}
}