Source: jbb.js

import { Mouse } from "./modules/mouse.js";
import { Keyboard } from "./modules/keyboard.js";
import { Font } from "./modules/fonts.js";
import { Jmage } from "./modules/image.js";
import { Canvas } from "./modules/canvas.js";
import { Time } from "./modules/time.js";
import { File } from "./modules/file.js";
import { Sound } from "./modules/sound.js";
import { Physics } from "./modules/physics.js";
import { Maths } from "./modules/maths.js";
import { Joy } from "./modules/joy.js";
import { Stubs } from "./modules/stubs.js";

/**
 * The main jBB engine class that orchestrates all modules and provides the core object-oriented API.
 */
export class jBB{
	/** @type {function} */
	mainloop;
	/** @private */
	#mouse; #keyboard; #joy; #stubs;
	/** @private */
	#canvas; #file; #sound; #physics; 
	/** @private */
	#currentFont;
	/** @private */
	#time; #math;
	/** @private */
	#loadingPromises = [];
	/** @private */
	#loopStarted = false;

	/**
	 * @param {number} [width=640] The width of the main graphics canvas.
	 * @param {number} [height=480] The height of the main graphics canvas.
	 * @param {function} mainLoop The user-defined function that will be called every frame.
	 */
	constructor(width = 640, height = 480, mainLoop){
		this.mainloop = mainLoop;

		this.#canvas = new Canvas(width, height);

		this.#mouse = new Mouse();
		this.#keyboard = new Keyboard();
		this.#time = new Time();
		this.#joy = new Joy();
		this.#file = new File();
		this.#sound = new Sound();
		this.#math = new Maths();
		this.#physics = new Physics();
		this.#stubs = new Stubs();
	}

	/**
	 * Starts the game engine. This should be called after all initial assets have been queued for loading.
	 * It will display a "Loading..." message, wait for all assets to load, and then start the main loop.
	 */
	start() {
		if (this.#loopStarted) return;
		this.#loopStarted = true;

		(async () => {
			this.#canvas.context.save();
			this.#canvas.context.fillStyle = 'gray';
			this.#canvas.context.fillRect(0, 0, this.#canvas.width, this.#canvas.height);
			this.#canvas.context.fillStyle = 'white';
			this.#canvas.context.font = '20px sans-serif';
			this.#canvas.context.textAlign = 'center';
			this.#canvas.context.fillText("Loading...", this.#canvas.width / 2, this.#canvas.height / 2);
			this.#canvas.context.restore();

			try {
				await Promise.all(this.#loadingPromises);
			} catch (error) {
				console.error("jBB: Error during asset loading.", error);
				this.#canvas.context.save();
				this.#canvas.context.fillStyle = 'red';
				this.#canvas.context.font = '20px sans-serif';
				this.#canvas.context.textAlign = 'center';
				this.#canvas.context.fillText("Error loading assets. Check console.", this.#canvas.width / 2, this.#canvas.height / 2 + 30);
				this.#canvas.context.restore();
				return;
			}

			this.#canvas.element.focus();
			window.requestAnimationFrame(this.render.bind(this));
		})();
	}

	/**
	 * The active 2D rendering context.
	 * @returns {CanvasRenderingContext2D}
	 */
	get canvas(){
		return this.#canvas.context;
	}

	/**
	 * @private
	 * The core render loop that calls the user's mainloop and performs per-frame cleanup.
	 */
	render(){
		window.requestAnimationFrame(this.render.bind(this));

		this.#joy.update(); // Poll gamepad state each frame
		this.mainloop();

		this.#keyboard.clearHits();
		this.#mouse.clearHits();
	}

	/**
	 * This command will wipe the canvas clean of any graphics or text present and reset the canvas back to the color defined in the ClsColor command
	 */
	cls(){
		this.#canvas.context.fillStyle = this.#canvas.clsColor.rgba();
		this.#canvas.context.setTransform(1, 0, 0, 1, 0, 0);
		this.#canvas.context.fillRect(0, 0, this.#canvas.width, this.#canvas.height);
	}

	/**
	 * Sets the color used by the `cls()` command.
	 * @param {number} [red=0] The red component (0-255).
	 * @param {number} [green=0] The green component (0-255).
	 * @param {number} [blue=0] The blue component (0-255).
	 */
	clsColor(red = 0, green = 0, blue = 0){
		this.#canvas.clsColor.set(red, green, blue, 1.0);
	}

	/**
	 * This command sets the drawing color (using RGB values) for all subsequent drawing commands (Line, Rect, Text, etc.).
	 * 
	 * @param {number} [red=255] The red component (0-255).
	 * @param {number} [green=255] The green component (0-255).
	 * @param {number} [blue=255] The blue component (0-255).
	 * @param {number} [alpha=255] The alpha component (0-255 or 0.0-1.0).
	 */
	color(red = 255, green = 255, blue = 255, alpha = 255){
		this.#canvas.drawColor.set(red, green, blue, alpha);
		this.#canvas.context.fillStyle = this.#canvas.drawColor.rgba();
		this.#canvas.context.strokeStyle = this.#canvas.drawColor.rgba();
	}

	/**
	 * Returns the width of the main graphics canvas.
	 * @returns {number}
	 */
	graphicsWidth(){
		return this.#canvas.width;
	}

	/**
	 * Returns the height of the main graphics canvas.
	 * @returns {number}
	 */
	graphicsHeight(){
		return this.#canvas.height;
	}

	/**
	 * Enables or disables image smoothing (bilinear filtering) for scaled images.
	 * @param {boolean} [value=false] True to enable smoothing, false to disable it (pixelated look).
	 */
	tFormFilter(value = false){
		this.#canvas.context.imageSmoothingEnabled = value;
	}

	/**
	 * Sets the composition mode for all subsequent drawing operations.
	 * @param {string} mode The blend mode to apply (e.g., "add", "multiply", "screen").
	 */
	setBlendMode(mode) {
		this.#canvas.setBlendMode(mode);
	}

	/**
	 * Sets the current drawing buffer to an image or the backbuffer.
	 * @param {Jmage | null} buffer The Jmage instance to use as a buffer, or null to reset to the screen.
	 */
	setBuffer(buffer) {
		this.#canvas.setTarget(buffer);
	}

	/**
	 * Returns a token representing the main screen buffer.
	 * @returns {null} A null value representing the backbuffer.
	 */
	backBuffer() {
		return null;
	}

	/**
	 * Creates a new, blank, drawable image.
	 * @param {number} width The width of the image.
	 * @param {number} height The height of the image.
	 * @returns {Jmage} A new Jmage instance.
	 */
	createImage(width, height) {
		let img = new Jmage(this);
		img.createBlank(width, height);
		return img;
	}

	/**
	 * Grabs a portion of the current drawing buffer into an existing image.
	 * The grab area is defined by the target image's dimensions.
	 * @param {Jmage} targetImage The destination image to copy the pixels to.
	 * @param {number} x The top-left x-coordinate of the grab area in the source buffer.
	 * @param {number} y The top-left y-coordinate of the grab area in the source buffer.
	 */
	grabImage(targetImage, x, y) {
		const bufferCtx = targetImage.getBufferContext();
		bufferCtx.drawImage(this.#canvas.context.canvas, x, y, targetImage.width, targetImage.height, 0, 0, targetImage.width, targetImage.height);
	}

	/**
	 * Creates a brush object from a tileset image for use with TileBlock.
	 * @param {Jmage} tilesetImage The image containing the tiles.
	 * @param {number} tileWidth The width of a single tile.
	 * @param {number} tileHeight The height of a single tile.
	 * @returns {object|null} A brush object or null if the image is invalid.
	 */
	loadBrush(tilesetImage, tileWidth, tileHeight) {
		if (!tilesetImage || !tilesetImage.width) return null;
		return {
			image: tilesetImage,
			tileWidth: tileWidth,
			tileHeight: tileHeight,
			tilesPerRow: Math.floor(tilesetImage.width / tileWidth)
		};
	}

	/**
	 * Draws a map of tiles using a specified brush.
	 * @param {object} brush The brush object created by LoadBrush.
	 * @param {Array<Array<number>>} mapData A 2D array of tile IDs. Tile ID 0 is considered empty.
	 * @param {number} [destX=0] The top-left X screen coordinate to start drawing the map.
	 * @param {number} [destY=0] The top-left Y screen coordinate to start drawing the map.
	 */
	tileBlock(brush, mapData, destX = 0, destY = 0) {
		if (!brush || !mapData || !brush.image.el) return;

		const context = this.#canvas.context;
		const tileset = brush.image.el;

		for (let row = 0; row < mapData.length; row++) {
			for (let col = 0; col < mapData[row].length; col++) {
				const tileID = mapData[row][col];
				if (tileID <= 0) continue; // 0 or less is an empty tile

				const tileIndex = tileID - 1; // Map is 1-based, tile indices are 0-based
				const sourceX = (tileIndex % brush.tilesPerRow) * brush.tileWidth;
				const sourceY = Math.floor(tileIndex / brush.tilesPerRow) * brush.tileHeight;

				context.drawImage(tileset, sourceX, sourceY, brush.tileWidth, brush.tileHeight, destX + (col * brush.tileWidth), destY + (row * brush.tileHeight), brush.tileWidth, brush.tileHeight);
			}
		}
	}

	/**
	 * This command will draw a rectangle in the current drawing Color starting at the location specified. 
	 * The last parameter determines if the rectangle is filled or just a 'box'.
	 * 
	 * @param {*} x x coordinate to begin drawing the rectangle 
	 * @param {*} y y coordinate to begin drawing the rectangle 
	 * @param {*} width how wide to make the rectangle in pixels
	 * @param {*} height how tall to make the rectangle in pixels 
	 * @param {*} filled False for unfilled and True for filled 
	 */
	rect(x, y, width, height, filled = true){
		if(filled){
			this.#canvas.context.fillRect(x, y, width, height);
		}else{
			this.#canvas.context.strokeRect(x, y, width, height);
		}
	}

	/**
	 * This command draws a line, in the current drawing color, from one point on the 
	 * screen to another (from the x,y to x1,y1 location).
	 * 
	 * @param {number} x1 The starting x-coordinate of the line.
	 * @param {number} y1 The starting y-coordinate of the line.
	 * @param {number} x2 The ending x-coordinate of the line.
	 * @param {number} y2 The ending y-coordinate of the line.
	 */
	line(x1, y1, x2, y2){
		this.#canvas.context.beginPath();
		this.#canvas.context.moveTo(x1, y1);
		this.#canvas.context.lineTo(x2, y2);
		this.#canvas.context.stroke();
	}

	/**
	 * Used to put a pixel on the screen defined by its x, y location in the current 
	 * drawing color.
	 * @param {number} x The x-coordinate of the pixel.
	 * @param {number} y The y-coordinate of the pixel.
	 */
	plot(x, y){
		this.rect(x, y, 1, 1, true);
	}

	/**
	 * Reads the color data of a single pixel from the main canvas.
	 * @param {number} x The x-coordinate of the pixel.
	 * @param {number} y The y-coordinate of the pixel.
	 * @returns {{r: number, g: number, b: number, a: number}} An object with the RGBA color components.
	 */
	readPixel(x, y) {
		return this.#canvas.readPixel(x, y);
	}

	/**
	 * Use this to draw an oval shape at the screen coordinates of your choice. You can make the oval solid or hollow.
	 * @param {number} x The x-coordinate of the oval's center.
	 * @param {number} y The y-coordinate of the oval's center.
	 * @param {*} width how wide to make the oval 
	 * @param {*} height how high to make the oval 
	 * @param {*} filled true to make the oval filled
	 */
	oval(x, y, width, height, filled = true){
		this.#canvas.context.beginPath();
		this.#canvas.context.ellipse(x, y, width, height, 0, 0, 2 * Math.PI, false);
		if(filled){
			this.#canvas.context.fill();
		}else{
			this.#canvas.context.stroke();
		}
		this.#canvas.context.closePath();
	}

	/**
	 * Enables or disables automatic centering of the handle for all subsequently loaded images.
	 * @param {boolean} [value=false] True to enable auto-mid-handling, false to disable.
	 */
	autoMidHandle(value = false){
		this.#canvas.autoMidHandle = value;
	}

	/**
	 * @private
	 * Checks if auto-mid-handling is globally enabled.
	 * @returns {boolean}
	 */
	isAutoMidHandle(){
		return this.#canvas.autoMidHandle;
	}

	/**
	 * Sets the handle of a specific image to its center.
	 * @param {Jmage} img The Jmage object to modify.
	 */
	midHandleImage(img){
		if(img && typeof img.midHandle === 'function'){
			img.midHandle();
		}
	}

	/**
	 * Loads a static image and queues it for processing.
	 * @param {string} path The path to the image file.
	 * @returns {Jmage} A Jmage instance representing the image.
	 */
	loadImage(path){
		const img = new Jmage(this);
		const loadingPromise = img.load(path);
		this.#loadingPromises.push(loadingPromise);
		return img;
	}

	/**
	 * Loads an animated image (spritesheet) and queues it for processing.
	 * @param {string} path The path to the spritesheet file.
	 * @param {number} width The width of a single frame.
	 * @param {number} height The height of a single frame.
	 * @param {number} first The index of the first frame in the animation sequence (1-based).
	 * @param {number} count The total number of frames in the animation sequence.
	 * @returns {Jmage} A Jmage instance representing the animated image.
	 */
	loadAnimImage(path, width, height, first, count){
		const img = new Jmage(this);
		// loadAnim() now returns a promise
		const loadingPromise = img.loadAnim(path, width, height, first, count);
		this.#loadingPromises.push(loadingPromise);
		return img;
	}

	/**
	 * Draws an image to the active buffer.
	 * @param {Jmage} img The Jmage object to draw.
	 * @param {number} x The x-coordinate to draw the image at (relative to its handle).
	 * @param {number} y The y-coordinate to draw the image at (relative to its handle).
	 * @param {number} [frame] The animation frame to draw (1-based).
	 */
	drawImage(img, x, y, frame){
		img.draw(x, y, frame);
	}

	/**
	 * rotate an image a specified number of degrees
	 * @param {Jmage} img The Jmage object to rotate.
	 * @param {number} value The rotation in degrees.
	 */
	rotateImage(img, value){
		img.rotate(value);
	}

	/**
	 * Returns the width of a single frame of an image.
	 * @param {Jmage} image The Jmage object.
	 * @returns {number} The width in pixels.
	 */
	imageWidth(image){
		return image.width;
	}

	/**
	 * Returns the height of a single frame of an image.
	 * @param {Jmage} image The Jmage object.
	 * @returns {number} The height in pixels.
	 */
	imageHeight(image){
		return image.height;
	}

	/**
	 * Returns the handle of an image.
	 * @param {Jmage} image The Jmage object.
	 * @returns {{x: number, y: number}} An object containing the x and y coordinates of the handle.
	 */
	imageHandle(image){
		return image.handle;
	}

	/**
	 * Returns the x-coordinate of an image's handle.
	 * @param {Jmage} image The Jmage object.
	 * @returns {number} The x-coordinate of the handle.
	 */
	imageXHandle(image){
		return image.handle.x;
	}

	/**
	 * Returns the y-coordinate of an image's handle.
	 * @param {Jmage} image The Jmage object.
	 * @returns {number} The y-coordinate of the handle.
	 */
	imageYHandle(image){
		return image.handle.y;
	}

	/**
	 * Rescales an image. Using a negative value will flip the image on that axis.
	 * @param {Jmage} image The Jmage object to scale.
	 * @param {number} x The horizontal scale factor (e.g., 1.0 for 100%, 2.0 for 200%).
	 * @param {number} y The vertical scale factor.
	 */
	scaleImage(image, x, y){
		image.scale(x, y);
	}

	/**
	 * Creates a copy of an image. The new image shares the same underlying pixel data
	 * but has its own independent transformation properties (handle, scale, rotation).
	 * @param {Jmage} img The Jmage instance to copy.
	 * @returns {Jmage} A new Jmage instance.
	 */
	copyImage(img){
		if (typeof img.clone !== 'function') throw new Error("Object is not cloneable, it is missing a clone() method.");
		return img.clone();
	}

	/**
	 * Loads a font file and queues it for processing.
	 * @param {string} path The path to the font file (e.g., .ttf, .woff).
	 * @param {string} name The name to assign to the font-family for CSS.
	 * @returns {Font} A Font instance representing the loaded font.
	 */
	loadFont(path, name){
		const font = new Font(this);
		const loadingPromise = font.load(path, name);
		this.#loadingPromises.push(loadingPromise);
		return font;
	}

	/**
	 * Sets the active font for subsequent text drawing operations.
	 * @param {Font} fnt The Font object to set as active.
	 * @param {number} size The font size in pixels.
	 * @param {boolean} [bold=false] True to make the font bold.
	 * @param {boolean} [italic=false] True to make the font italic.
	 * @param {number} [weight=0] An optional font weight (e.g., 700). Overrides `bold` if set.
	 */
	setFont(fnt, size, bold = false, italic = false, weight = 0){
		this.#currentFont = fnt;
		fnt.set(size, bold, italic, weight);
	}

	/**
	 * Draws text to the screen using the currently active font.
	 * @param {number} x The x-coordinate to draw the text at.
	 * @param {number} y The y-coordinate to draw the text at.
	 * @param {string} value The text to draw.
	 */
	text(x, y, value){
		this.#currentFont.text(x, y, value);
	}

	fontWidth(){
		if(this.#currentFont) return this.#currentFont.width();
		return 0;
	}

	fontHeight(){
		if(this.#currentFont) return this.#currentFont.height();
		return 0;
	}

	/**
	 * Measures the width of a string using the currently active font.
	 * @param {string} value The string to measure.
	 * @returns {number|undefined} The width in pixels, or undefined if no font is set.
	 */
	stringWidth(value){
		if(this.#currentFont) return this.#currentFont.width(value);
	}

	/**
	 * Measures the height of a string using the currently active font.
	 * @param {string} value The string to measure.
	 * @returns {number|undefined} The height in pixels, or undefined if no font is set.
	 */
	stringHeight(value){
		if(this.#currentFont) return this.#currentFont.height(value);
	}

	/**
	 * Returns the x-coordinate of the mouse relative to the canvas.
	 * @returns {number} The x-coordinate.
	 */
	mouseX(){
		if (!this.#canvas || !this.#canvas.element) return 0;
		const rect = this.#canvas.element.getBoundingClientRect();
		return (this.#mouse.x - rect.left) * (this.#canvas.width / rect.width);
	}

	/**
	 * Returns the y-coordinate of the mouse relative to the canvas.
	 * @returns {number}
	 */
	mouseY(){
		if (!this.#canvas || !this.#canvas.element) return 0;
		const rect = this.#canvas.element.getBoundingClientRect();
		return (this.#mouse.y - rect.top) * (this.#canvas.height / rect.height);
	}

	/**
	 * Checks if a specific mouse button is currently held down.
	 * @param {number} button The button to check (1=left, 2=right, 3=middle).
	 * @returns {boolean}
	 */
	mouseDown(button) {
		return this.#mouse.mouseDown(button);
	}

	/**
	 * Checks if a specific mouse button was just pressed in the current frame.
	 * @param {number} button The button to check (1=left, 2=right, 3=middle).
	 * @returns {boolean}
	 */
	mouseHit(button) {
		return this.#mouse.mouseHit(button);
	}

	/**
	 * Checks if a specific key is currently being held down.
	 * @param {number} keyCode The key code to check.
	 * @returns {boolean} True if the key is down, false otherwise.
	 */
	keyDown(keyCode) {
		return this.#keyboard.keyDown(keyCode);
	}

	/**
	 * Checks if a specific key was pressed once. This is true for only one frame.
	 * @param {number} keyCode The key code to check.
	 * @returns {boolean} True if the key was just hit, false otherwise.
	 */
	keyHit(keyCode) {
		return this.#keyboard.keyHit(keyCode);
	}

	joyType(padIndex = 0) {
		return this.#joy.joyType(padIndex);
	}

	joyDown(button, padIndex = 0) {
		return this.#joy.joyDown(button, padIndex);
	}

	joyX(padIndex = 0) {
		return this.#joy.joyX(padIndex);
	}

	joyY(padIndex = 0) {
		return this.#joy.joyY(padIndex);
	}

	openFile(path) {
		return this.#file.openFile(path);
	}

	readFile(handle) {
		return this.#file.readFile(handle);
	}

	writeFile(handle, text) {
		return this.#file.writeFile(handle, text);
	}

	closeFile(handle) {
		return this.#file.closeFile(handle);
	}

	filePos(handle) {
		return this.#file.filePos(handle);
	}

	seekFile(handle, pos) {
		return this.#file.seekFile(handle, pos);
	}

	eof(handle) {
		return this.#file.eof(handle);
	}

	readDir(path) {
		return this.#file.readDir(path);
	}

	nextFile(handle) {
		return this.#file.nextFile(handle);
	}

	closeDir(handle) {
		return this.#file.closeDir(handle);
	}

	currentDir() {
		return this.#file.currentDir();
	}

	changeDir(path) {
		return this.#file.changeDir(path);
	}

	createDir(path) {
		return this.#file.createDir(path);
	}

	deleteDir(path) {
		return this.#file.deleteDir(path);
	}

	fileType(path) {
		return this.#file.fileType(path);
	}

	fileSize(path) {
		return this.#file.fileSize(path);
	}

	copyFile(sourcePath, destPath) {
		return this.#file.copyFile(sourcePath, destPath);
	}

	deleteFile(path){
		return this.#file.deleteFile(path);
	}

	fileExists(path) {
		return this.#file.fileExists(path);
	}

	downloadFile(path, downloadName) {
		return this.#file.downloadFile(path, downloadName);
	}

	async loadTextFile(path) {
		return this.#file.loadTextFile(path);
	}

	loadSound(path) {
		const { handle, promise } = this.#sound.loadSound(path);
		if (promise) this.#loadingPromises.push(promise);
		return handle;
	}

	freeSound(handle) {
		this.#sound.freeSound(handle);
	}

	loopSound(handle, loop) {
		this.#sound.loopSound(handle, loop);
	}

	soundVolume(handle, volume) {
		this.#sound.soundVolume(handle, volume);
	}

	soundPitch(handle, pitch) {
		this.#sound.soundPitch(handle, pitch);
	}

	soundPan(handle, pan) {
		this.#sound.soundPan(handle, pan);
	}

	playSound(soundHandle) {
		return this.#sound.playSound(soundHandle);
	}

	stopChannel(channelHandle) {
		this.#sound.stopChannel(channelHandle);
	}

	channelVolume(channelHandle, volume) {
		this.#sound.channelVolume(channelHandle, volume);
	}

	channelPlaying(channelHandle) {
		return this.#sound.channelPlaying(channelHandle);
	}

	playMusic(path, loop) {
		this.#sound.playMusic(path, loop);
	}

	stopMusic() {
		this.#sound.stopMusic();
	}

	pauseMusic() {
		this.#sound.pauseMusic();
	}

	resumeMusic() {
		this.#sound.resumeMusic();
	}

	musicVolume(volume) {
		this.#sound.musicVolume(volume);
	}

	rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
		return this.#physics.rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2);
	}

	/**
	 * Checks if two images, placed at specific coordinates, overlap based on their bounding boxes.
	 * @returns {boolean} True if the images' bounding boxes overlap, false otherwise.
	 */
	imagesOverlap(img1, x1, y1, img2, x2, y2) {
		return this.#physics.imagesOverlap(img1, x1, y1, img2, x2, y2);
	}

	imagesCollide(img1, x1, y1, frame1, img2, x2, y2, frame2) {
		return this.#physics.imagesCollide(img1, x1, y1, frame1, img2, x2, y2, frame2);
	}

	millisecs(){
		return this.#time.millisecs();
	}

	rand(min, max){
		return this.#math.rand(min, max);
	}

	pi(){
		return this.#math.pi();
	}

	float(value){
		return this.#math.float(value);
	}

	int(value){
		return this.#math.int(value);
	}

	// ============ stubs for non-implementable functions ============
	execute(command) {
		this.#stubs.execute(command);
	}

	getEnv(variable) {
		return this.#stubs.getEnv(variable);
	}

	setEnv(variable, value) {
		this.#stubs.setEnv(variable, value);
	}

	playCDTrack(track, mode) {
		this.#stubs.playCDTrack(track, mode);
	}

	stopCD() {
		this.#stubs.stopCD();
	}

	callDLL(dllFile, functionName, ...args) {
		return this.#stubs.callDLL(dllFile, functionName, ...args);
	}
}