//     ╔════════╗     ╔════════╗     ╔════════╗     ╔════════╗
//     ║        ║     ║        ║     ║        ║     ║        ║
// ╔═══╩════════╩═════╩════════╩═════╩════════╩═════╩════════╩═══╗
// ║    ███ █████ █   █ █████ █████ █████ █████ ███ █████ █      ║
// ║      █ █   █ ██  █ █   █   █   █   █ █   █  █  █   █ █      ║
// ║      █ █████ █ █ █ █████   █   █   █ █████  █  █████ █      ║
// ║  █   █ █   █ █  ██ █   █   █   █   █ █  █   █  █   █ █      ║
// ║   ███  █   █ █   █╻█   █   █   █████ █  ██ ███ █   █ █████  ║
// ║               ╻   ┃   ╻   ╷                  ____           ║
// ║   ⊙╶──────┼─╼━╋━━━╋━━━╋━━─┼───┼──────╴⊙     |===||  ─ ─ ─ ─ ╢
// ║               ╹   ┃   ╹   ╵                 ║    \\         ║
// ║  █████ █   █ ████ ╹█████ █████ ███ ████     ║ ↱↴  \\        ║
// ║  █   █ ██  █ █   █ █   █ █   █  █  █   █    ║ Ꝉ↲  ||        ║
// ║  █████ █ █ █ █   █ █████ █   █  █  █   █    ╠═╤═══╝'        ║
// ║  █   █ █  ██ █   █ █  █  █   █  █  █   █    ║⊙|└┐           ║
// ║  █   █ █   █ ████  █  ██ █████ ███ ████     ║‡_]┘           ║
// ╚═════════════════════════════════════════════════════════════╝

//                    ╔════════╗     ╔════════╗
//                    ║▒▒▒▒▒▒▒▒║     ║▒▒▒▒▒▒▒▒║
//                ╔═══╩════════╩═════╩════════╩═══╗
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
//                ╚═══════════════════════════════╝

//     ╔════════╗     ╔════════╗
//     ║░░░░░░░░║     ║░░░░░░░░║
// ╔═══╩════════╩═════╩════════╩═══╗
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
// ╚═══════════════════════════════╝

//                    ╔════════╗     ╔════════╗     ╔════════╗
//                    ║        ║     ║        ║     ║        ║
//                ╔═══╩════════╩═════╩════════╩═════╩════════╩═══╗
//                ║                                              ║
//                ║                                              ║
//                ║                                              ║
//                ║                                              ║
//                ║                                              ║
//                ║                                              ║
//                ╚══════════════════════════════════════════════╝

// ---------------------
// How to view this file
// ---------------------
// The file is split into sections, with headings in a large font designed to be viewed with a minimap in an editor like Sublime Text or VS Code.
// You should disable word wrapping in your editor (and any extra line spacing) to view the ASCII art.
//
// VS Code
// -------
// If you use VS Code, you can fold the sections with the command Fold All Regions.
//
// Recommended Code themes:
// * Codel
//
// Sublime Text
// ------------
// If you use Sublime Text, you can fold all functions and objects with Code Folding: Fold All.
// If you want to fold the sections specifically, you can install SyntaxFold https://packagecontrol.io/packages/SyntaxFold
// and configure region comments for JavaScript:
// {
// 	"config": [
// 		{
// 			"scope": "source.js",
// 			"startMarker": "#region",
// 			"endMarker": "#endregion"
// 		},
// 	]
// }
// and use the command SyntaxFold: Fold All.
//
// Recommended Sublime color schemes:
// * Spring (light and bright with orange comments)
// * cP-Code (medium-dark, sort of hazy / lower contrast theme)
// * Blackboard (dark)
// * Art School (light, with primary colors)
// * Flatland Dark/Black (dark, or darker; lots of orange)


// █████ █████ █████    █████ █     █████ █   █ █████ █   █ █████ █████      █     █ █
// █     █       █      █     █     █     ██ ██ █     ██  █   █   █         █     █   █
// █ ███ █████   █      █████ █     █████ █ █ █ █████ █ █ █   █   █████    █     █     █
// █   █ █       █      █     █     █     █   █ █     █  ██   █       █     █   █     █
// █████ █████   █      █████ █████ █████ █   █ █████ █   █   █   █████      █ █     █
//
// #region Get Elements </>

// Title screen elements
const titleScreen = document.getElementById("title-screen");
const startGameButton = document.getElementById("start-game");
const introContainer = document.getElementById("intro-container");
const replayIntroButton = document.getElementById("replay-intro");
const skipIntroButton = document.getElementById("skip-intro");
const resetScreenButton = document.getElementById("reset-screen");
const showCreditsButton = document.getElementById("show-credits");
const loadStatusLoaded = document.getElementById("load-status-loaded");
const loadStatusLoading = document.getElementById("load-status-loading");
const loadProgress = document.getElementById("load-progress");
const junkbotUndercoverTitle = document.getElementById("junkbot-undercover-logo");
// Level Select screen elements
const levelSelectScreen = document.getElementById("level-select-screen");
const levelList = document.getElementById("level-list");
const junkbotPagination = document.getElementById("junkbot-level-group-tabs");
const junkbotUndercoverPagination = document.getElementById("junkbot-undercover-level-group-tabs");
const backToTitleScreenButton = document.getElementById("back-to-title");
// Main game controls bar
const mainControlsBar = document.getElementById("main-controls");
const toggleInfoButton = document.getElementById("toggle-info");
const toggleFullscreenButton = document.getElementById("toggle-fullscreen");
const toggleMuteButton = document.getElementById("toggle-mute");
const toggleEditingButton = document.getElementById("toggle-editing");
const volumeSlider = document.getElementById("volume-slider");
const zoomInButton = document.getElementById("zoom-in");
const zoomOutButton = document.getElementById("zoom-out");
const backToLevelSelectButton = document.getElementById("back-to-level-select");
// Info screen
const infoBox = document.getElementById("info");
const controlsTableRows = document.querySelectorAll("#info table tr");
// Editor UI
const editorUI = document.getElementById("editor-ui");
const editorControlsBar = document.getElementById("editor-controls");
const levelDropdown = document.getElementById("level-dropdown");
const entitiesPalette = document.getElementById("entities-palette");
const entitiesScrollContainer = document.getElementById("entities-scroll-container");
const levelBoundsCheckbox = document.getElementById("level-bounds-checkbox");
const levelTitleInput = document.getElementById("level-title-input");
const levelHintInput = document.getElementById("level-hint-input");
const levelParInput = document.getElementById("level-par-input");
const saveButton = document.getElementById("save-world");
const openButton = document.getElementById("open-world");
const rewindButton = document.getElementById("rewind");
// Tests UI
const testsUI = document.getElementById("tests-ui");
const testsUL = document.getElementById("tests");
const testsInfo = document.getElementById("tests-info");
const testSpeedInput = document.getElementById("test-speed");
// const startButton = document.getElementById("start-tests");

// #endregion
//                                                 .-'';'-.
//                                               ,'   <_,-.`.
// █████ █     █████ ████  █████ █     █████    /)   ,--,_>\_\
// █     █     █   █ █   █ █   █ █     █       |'   (      \_ |
// █ ███ █     █   █ █████ █████ █     █████   |_    `-.    / |
// █   █ █     █   █ █   █ █   █ █         █    \`-.   ;  _(`/
// █████ █████ █████ ████  █   █ █████ █████     `.(    \/ ,'
//                                                 `-....-'
// #region Globals (declarations and basic initialization)

const SCREEN_TITLE = "SCREEN_TITLE";
const SCREEN_LEVEL_SELECT = "SCREEN_LEVEL_SELECT";
const SCREEN_LEVEL = "SCREEN_LEVEL";

const GAME_JUNKBOT = "GAME_JUNKBOT";
const GAME_JUNKBOT_UNDERCOVER = "GAME_JUNKBOT_UNDERCOVER";
const GAME_JANITORIAL_ANDROID = "GAME_JANITORIAL_ANDROID";
const GAME_TEST_CASES = "GAME_TEST_CASES";
const GAME_USER_CREATED = "GAME_USER_CREATED";

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

canvas.tabIndex = 0;
canvas.style.touchAction = "none";

document.body.append(canvas);

window.AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
const mainGain = audioCtx.createGain();
mainGain.connect(audioCtx.destination);

const viewport = { centerX: 0, centerY: 0, scale: 1 };
let keys = {};
let pointerEventCache = [];
let prevPointerDist = -1;
const enableMarginPanning = false;

let entities = [];
let wind = [];
let laserBeams = [];
let teleportEffects = [];
let collectBinTime = -1;
let currentLevel = {
	entities,
	title: "Custom World",
};
let playthroughEvents = []; // records your solution to the current level
let playbackEvents = []; // could be used for testing or a demo mode or just playing back what you just did
let playbackLevel = {}; // level object built from json diff patches; if gameplay behavior changes, a recording can still be played back without simulation using this, and compared to simulation for debugging
let levelLastFrame = {}; // for generating diff patches for playthrough recording
let winLoseState = "";
let moves = 0; // your score (lower is better); only picking up bricks counts, not putting them down.
let frameCounter = 0; // for precise recording/playback
let desynchronized = false;
let idCounter = 0;
const getID = () => {
	idCounter += 1;
	return idCounter;
};
const diffPatcher = window.jsondiffpatch.create({
	// objectHash allows array operations to work reasonably; it defines the identity of objects
	// entities have id, but decals don't (for now anyway), so name+x+y is for decals
	objectHash: (obj) => obj.id ?? (`${obj.name}@${obj.x},${obj.y}`),
});

let editorLevelState;
const undos = [];
const redos = [];
const clipboard = {};

const mouse = { x: undefined, y: undefined, worldX: undefined, worldY: undefined };
let dragging = [];
let selectionBox;

const snapX = 15;
const snapY = 18; // or 6 for thin brick heights

const TELEPORT_COOLDOWN = 50;
const TELEPORT_EFFECT_PERIOD = 20;

const targetFPS = 18;
// let targetFPS = 15;
// addEventListener("mousemove", (event) => {
// 	targetFPS = event.clientX / window.innerWidth * 15;
// });

let lastSimulateTime = 0;
// The higher this value, the less the fps display will reflect temporary variations
// A value of 1 will only keep the last value
const fpsSmoothing = 20;
let smoothedFrameTime = 0;

let showDebug = false;
let muted = false;
let paused = false;
let editing = false;
let testing = false;
let rewindingWithButton = false;

// eslint-disable-next-line no-empty-function, no-unused-vars
let updateEditorUIForLevelChange = (level) => { };

let smoothedSwitchConnectionAlpha = 0;

const brickColorNames = [
	"white",
	"red",
	"green",
	"blue",
	"yellow",
	"gray",
];
const brickWidthsInStuds = [1, 2, 3, 4, 6, 8];

// #endregion
//                                                       ___..__
// █   █ █████ █     █████ █████ █████ █████    __..--""" ._ __.'
// █   █ █     █     █   █ █     █   █ █                    "-..__
// █████ █████ █     █████ █████ █████ █████              '"--..__";
// █   █ █     █     █     █     █  █      █   ___        '--...__"";
// █   █ █████ █████ █     █████ █  ██ █████      `-..__ '"---..._;"
//                                                      """"----'
// #region Helpers

let frameStartTime = 0;
const debugs = {};
const debugWorldSpaceRects = [];
const debug = (subject, ...texts) => {
	debugs[subject] = debugs[subject] || {};
	debugs[subject].time = frameStartTime;
	debugs[subject].text = texts.join(" ");
};
const debugWorldSpaceRect = (x, y, width, height) => {
	if (showDebug) {
		debugWorldSpaceRects.push({ x, y, width, height });
	}
};
// compare junkbot's animations with a video of the original game
// const testVideo = document.createElement("video");
// testVideo.src = "junkbot-test-video.mp4";
// testVideo.loop = true;
// testVideo.muted = true;
// testVideo.currentTime = 2;
// try {
// 	testVideo.currentTime = parseFloat(localStorage.comparisonVideoTime);
// } catch (e) { }
// let aJunkbot;

const wrapContents = (target, wrapper) => {
	for (const child of target.childNodes) {
		wrapper.appendChild(child);
	}
	target.appendChild(wrapper);
	return wrapper;
};

const toggleFullscreen = () => {
	if (document.fullscreenElement) {
		document.exitFullscreen();
	} else {
		document.documentElement.requestFullscreen();
	}
};

const showMessageBox = (message, {
	buttons = [
		{
			label: "OK",
			isDefault: true,
			action: () => { /* the message box will close */ },
		}
	],
	className,
} = {}) => {
	// @TODO: support Esc, Enter, etc.
	const messageBoxContainer = document.createElement("div");
	messageBoxContainer.className = "dialog-container";
	const messageBox = document.createElement("div");
	messageBox.className = "dialog metal-border";
	if (className) {
		messageBox.classList.add(className);
	}
	const messageContentEl = document.createElement("div");
	messageContentEl.className = "message-content";
	if (typeof message === "string") {
		messageContentEl.textContent = message;
	} else {
		messageContentEl.append(...message);
	}
	messageBox.append(messageContentEl);
	const buttonGroup = document.createElement("div");
	buttonGroup.className = "button-group";
	messageBox.append(buttonGroup);
	let closing = false;
	const waitForTransitionEnd = () => {
		return new Promise((resolve) => {
			messageBox.addEventListener("transitionend", () => {
				resolve();
			});
		});
	};
	const closeMessageBox = async (animate) => {
		if (!messageBoxContainer.parentNode) {
			return Promise.resolve();
		}
		if (closing) {
			await waitForTransitionEnd();
			return;
		}
		closing = true;
		if (animate) {
			messageBox.style.transition = "transform 1s linear";
			messageBox.style.transform = "translateX(-100vw)";
			await waitForTransitionEnd();
			messageBoxContainer.remove();
		} else {
			messageBoxContainer.remove();
			return Promise.resolve();
		}
	};
	for (const { label, isDefault, action } of buttons) {
		const button = document.createElement("button");
		button.className = "generic-button generic-sound";
		button.onclick = () => {
			closeMessageBox(true); // must be before action so a message box can be shown in the action
			action?.();
		};
		if (isDefault) {
			button.classList.add("default-button");
			button.focus();
		}
		if (label === "OK") {
			button.classList.add("ok-button");
		}
		button.textContent = label;
		button.style.margin = "10px";
		button.style.marginTop = "20px";
		buttonGroup.append(button);
	}
	messageBoxContainer.append(messageBox);
	document.body.append(messageBoxContainer);
	messageBox.style.transform = "translateY(-100vh)";
	messageBox.style.transition = "transform 1s linear";
	requestAnimationFrame(() => { // wait just before the next paint
		// eslint-disable-next-line no-unused-expressions
		document.body.offsetHeight; // force a reflow
		// trigger the initial transition
		messageBox.style.transform = "translateY(0)";
	});

	messageBoxContainer.close = closeMessageBox;

	return messageBoxContainer;
};

// Error messages are important and shouldn't be lost due to timing of close vs open during navigation (immediate close after open).
// Other dialogs are fine to close due to navigation.
const nonErrorDialogs = [];
const closeNonErrorDialogs = () => {
	// When loading the next level for example, we want to close all non-error dialogs
	// When hitting Select Level, we want to wait for the transition to finish before changing screens
	const promise = Promise.all(nonErrorDialogs.map((dialog) => dialog.close(false)));
	nonErrorDialogs.length = 0;
	return promise;
};

const showPrompt = (message, defaultText = "") => {
	const p = document.createElement("p");
	p.textContent = message;
	const input = document.createElement("input");
	input.type = "text";
	input.value = defaultText;
	input.style.marginTop = "10px";
	input.style.marginBottom = "10px";
	setTimeout(() => {
		input.focus();
		input.select();
	}, 0);
	return new Promise((resolve) => {
		nonErrorDialogs.push(showMessageBox([p, input], {
			buttons: [
				{ label: "Cancel", action: () => resolve(undefined) },
				{ label: "OK", action: () => resolve(input.value) },
			]
		}));
	});
};

const showErrorMessage = (message, error) => {
	const content = document.createElement("div");
	// content.style.maxWidth = "600px";
	content.innerHTML = "<p style='white-space: pre-wrap;'></p>";
	if (error) {
		content.innerHTML += "<details><summary><span>Details</span></summary><pre></details>";

		// Chrome includes the error message in the error.stack string, whereas Firefox doesn't.
		// Also note that there can be Exception objects that don't have a message (empty string) but a name,
		// for instance Exception { message: "", name: "NS_ERROR_FAILURE", ... } for running out of memory in Firefox.
		let errorString = error.stack;
		if (!errorString) {
			errorString = error.toString();
		} else if (error.message && errorString.indexOf(error.message) === -1) {
			errorString = `${error.toString()}\n\n${errorString}`;
		} else if (error.name && errorString.indexOf(error.name) === -1) {
			errorString = `${error.name}\n\n${errorString}`;
		}
		const pre = content.querySelector("pre");
		pre.textContent = errorString;
		pre.style.background = "white";
		pre.style.color = "#333";
		// pre.style.background = "#A00";
		// pre.style.color = "white";
		pre.style.fontFamily = "monospace";
		pre.style.width = "500px";
		pre.style.maxWidth = "100%";
		pre.style.overflow = "auto";
	}
	content.querySelector("p").textContent = message;

	showMessageBox([content]);

	if (error) {
		window.console?.error?.("(Showing error message box for:)", message, error);
	} else {
		window.console?.error?.("(Showing error message box for:)", message);
	}
};

const levelNameToSlug = (levelName) => levelName
	.replace(/'/g, "") // remove apostrophes (because "don-t" and "it-s" look stupid)
	.replace(/[^a-z0-9]/gi, "-") // replace non-alphanumeric characters with dashes
	.replace(/-{2,}/g, "-") // replace multiple dashes with a single dash
	.replace(/^-+|-+$/g, "") // remove leading and trailing dashes (effectively trimming whitespace etc.)
	.toLowerCase();

const gameNameToSlug = (gameName) => levelNameToSlug(gameName)
	.replace(/^game-/, "") // for converting enum names (GAME_*) to slugs, especially for loose comparison
	.replace(/(uc|undercover)/, "2")
	.replace(/1/g, "")
	.replace(/janitorial-android/, "junkbot3")
	.replace(/test-cases|run-tests|test-runner/, "tests")
	.replace(/user-created|my-computer|local/, "local")
	.replace(/-/g, "");

const canonicalSlugToGame = {
	"junkbot": GAME_JUNKBOT,
	"junkbot2": GAME_JUNKBOT_UNDERCOVER,
	"junkbot3": GAME_JANITORIAL_ANDROID,
	"tests": GAME_TEST_CASES,
	"local": GAME_USER_CREATED,
};

const parseGameID = (gameName) => canonicalSlugToGame[gameNameToSlug(gameName)];

const levelGroupToSlug = (groupName, gameName) => {
	const game = parseGameID(gameName);
	const levelGroupNumber = parseInt(groupName.replace(/\D/g, ""), 10);
	if (isFinite(levelGroupNumber)) {
		if (game === GAME_JUNKBOT_UNDERCOVER) {
			return `basement-${levelGroupNumber}`;
		} else if (game === GAME_JUNKBOT) {
			return `building-${levelGroupNumber}`;
		} else {
			return `page-${levelGroupNumber}`;
		}
	} else {
		return undefined;
	}
};

const storageKeys = {
	// best score (fewest moves)
	score: (levelName) => `janitorial-android:score:${levelNameToSlug(levelName)}`,
	// a recording that can be played back (corresponding to the best score)
	solutionRecording: (levelName) => `janitorial-android:solution-recording:${levelNameToSlug(levelName)}`,

	// level editor auto-save
	level: (levelName) => `janitorial-android:level:${levelNameToSlug(levelName)}`,
	levelPrefix: "janitorial-android:level:", // for enumeration

	// settings
	muteSoundEffects: "janitorial-android:mute-sound-effects",
	muteMusic: "janitorial-android:mute-music",
	volume: "janitorial-android:volume",

	// dev helpers
	showDebug: "janitorial-android:debug",
	comparisonVideoTime: "janitorial-android:comparison-video-time",
};

const floor = (x, multiple) => Math.floor(x / multiple) * multiple;
const round = (x, multiple) => Math.round(x / multiple) * multiple;

const arrayRemove = (array, value) => {
	if (array === entities) {
		window.console?.warn("arrayRemove on entities array is unsafe if iterating over entities. Set flag entity.removeBeforeRender instead.");
	}
	const index = array.indexOf(value);
	if (index !== -1) {
		array.splice(index, 1);
	}
};


const sortEntitiesForRendering = (entities) => {
	entities.sort((a, b) => b.y - a.y);

	let n = entities.length;
	do {
		let newN = 0;
		for (let i = 1; i < n; i++) {
			const a = entities[i - 1];
			const b = entities[i];
			if (
				a.y + a.height < b.y ||
				b.x + b.width <= a.x
			) {
				entities[i - 1] = b;
				entities[i] = a;
				newN = i;
			}
		}
		n = newN;
	} while (n > 1);
	// from https://en.wikipedia.org/wiki/Bubble_sort
	// procedure bubbleSort(A : list of sortable items)
	// 	n := length(A)
	// 	repeat
	// 		new_n := 0
	// 		for i := 1 to n - 1 inclusive do
	// 			if A[i - 1] > A[i] then
	// 				swap(A[i - 1], A[i])
	// 				new_n := i
	// 			end if
	// 		end for
	// 		n := new_n
	// 	until n ≤ 1
	// end procedure
};

// #endregion
//                                                                                                  ____
// █████ █████ █     █     ███ █████ ███ █████ █   █    █████ █████ █████ █████ █████              |_|__|
// █     █   █ █     █      █  █      █  █   █ ██  █      █   █     █       █   █          -- /░░░\ |__|_|
// █     █   █ █     █      █  █████  █  █   █ █ █ █      █   █████ █████   █   █████    ----|░░░░░||_|__|
// █     █   █ █     █      █      █  █  █   █ █  ██      █   █         █   █       █     ---|░░░░░||__|_|
// █████ █████ █████ █████ ███ █████ ███ █████ █   █      █   █████ █████   █   █████      -- \░░░/ |_|__|
//                                                                                                 |__|_|
// #region Collision Tests

const rectanglesIntersect = (ax, ay, aw, ah, bx, by, bw, bh) => (
	ax + aw > bx &&
	ax < bx + bw &&
	ay + ah > by &&
	ay < by + bh
);

const rectangleLevelBoundsCollisionTest = (x, y, width, height) => {
	const { bounds } = currentLevel;
	if (!bounds) {
		return;
	}
	if (x < bounds.x) {
		return { type: "levelBounds", x: bounds.x - 15, y: bounds.y, width: 15, height: bounds.height };
	}
	if (y < bounds.y) {
		return { type: "levelBounds", x: bounds.x, y: bounds.y - 18, width: bounds.width, height: 18 };
	}
	if (x + width > bounds.x + bounds.width) {
		return { type: "levelBounds", x: bounds.x + bounds.width, y: bounds.y, width: 15, height: bounds.height };
	}
	if (y + height > bounds.y + bounds.height) {
		return { type: "levelBounds", x: bounds.x, y: bounds.y + bounds.height, width: bounds.width, height: 18 };
	}
};
const rectangleCollisionTest = (x, y, width, height, filter) => {
	const boundsHit = rectangleLevelBoundsCollisionTest(x, y, width, height);
	if (boundsHit && filter(boundsHit)) {
		return boundsHit;
	}
	for (const otherEntity of entities) {
		if (
			!otherEntity.grabbed &&
			filter(otherEntity) &&
			rectanglesIntersect(
				x,
				y,
				width,
				height,
				otherEntity.x,
				otherEntity.y,
				otherEntity.width,
				otherEntity.height,
			)
		) {
			return otherEntity;
		}
	}
	return null;
};
const rectangleCollisionAll = (x, y, width, height, filter) => {
	const boundsHit = rectangleLevelBoundsCollisionTest(x, y, width, height);
	return ((boundsHit && filter(boundsHit)) ? [boundsHit] : []).concat(entities.filter((otherEntity) => (
		!otherEntity.grabbed &&
		filter(otherEntity) &&
		rectanglesIntersect(
			x,
			y,
			width,
			height,
			otherEntity.x,
			otherEntity.y,
			otherEntity.width,
			otherEntity.height,
		)
	)));
};
const entityCollisionTest = (entityX, entityY, entity, filter) => (
	// Note: make sure not to use entity.x/y!
	rectangleCollisionTest(
		entityX,
		entityY,
		entity.width,
		entity.height,
		(otherEntity) => otherEntity !== entity && filter(otherEntity)
	)
);
const entityCollisionAll = (entityX, entityY, entity, filter) => (
	// Note: make sure not to use entity.x/y!
	rectangleCollisionAll(
		entityX,
		entityY,
		entity.width,
		entity.height,
		(otherEntity) => otherEntity !== entity && filter(otherEntity)
	)
);
const raycast = ({ startX, startY, width, height, directionX, directionY, maxSteps, entityFilter }) => {
	let steps = 0;
	let x = startX;
	let y = startY;
	while (steps < maxSteps) {
		x += 15 * directionX;
		y += 18 * directionY;
		debugWorldSpaceRect(x, y, width, height);
		const hit = rectangleCollisionTest(x, y, width, height, entityFilter);
		if (hit) {
			return { steps, hit };
		}
		steps += 1;
	}
	return { steps, hit: null };
};

const entitiesWithinSelection = (selectionBox) => {
	const minX = Math.min(selectionBox.x1, selectionBox.x2);
	const maxX = Math.max(selectionBox.x1, selectionBox.x2);
	const minY = Math.min(selectionBox.y1, selectionBox.y2);
	const maxY = Math.max(selectionBox.y1, selectionBox.y2);
	return rectangleCollisionAll(
		minX,
		minY,
		maxX - minX,
		maxY - minY,
		() => true
	);
};

// #endregion
//                                                                                                        _                             __                       _   _
// █████ █   █ █████ ███ █████ █   █    █████ █████ █████ █████ █████ █████ ███ █████ █████      r-.._   | |        __               __(  )_____                | | | |
// █     ██  █   █    █    █   █   █    █     █   █ █       █   █   █ █   █  █  █     █        __|    |_r   -.._   {  } __________  |           |              _| |_| |_______
// █████ █ █ █   █    █    █    █ █     █████ █████ █       █   █   █ █████  █  █████ █████   |    _            |  _||_|   [_][_] L |  _  _  _  |,-|,-|,-|    |   [_][_][_][_]|
// █     █  ██   █    █    █     █      █     █   █ █       █   █   █ █  █   █  █         █   |   [_]           | |               | | [_][_][_]          | .-'                |
// █████ █   █   █   ███   █     █      █     █   █ █████   █   █████ █  ██ ███ █████ █████   |_________________| |_______________| |____________________| |__________________|
//
// #region Entity Factories

const makeBrick = ({ x, y, widthInStuds, colorName, fixed = false }) => {
	return {
		id: getID(),
		type: "brick",
		x,
		y,
		widthInStuds,
		width: widthInStuds * 15,
		height: 18,
		colorName,
		fixed,
	};
};
const makeJunkbot = ({ x, y, facing = 1, armored = false }) => {
	return {
		id: getID(),
		type: "junkbot",
		x,
		y,
		width: 2 * 15,
		height: 4 * 18,
		facing,
		armored,
		losingShield: false,
		losingShieldTime: 0,
		animationFrame: 0,
		headLoaded: false,
	};
};
const makeGearbot = ({ x, y, facing = 1 }) => {
	return {
		id: getID(),
		type: "gearbot",
		x,
		y,
		width: 2 * 15,
		height: 2 * 18,
		facing,
		animationFrame: 0,
	};
};
const makeClimbbot = ({ x, y, facing = 1, facingY = 0 }) => {
	return {
		id: getID(),
		type: "climbbot",
		x,
		y,
		width: 2 * 15,
		height: 2 * 18,
		facing,
		facingY,
		animationFrame: 0,
		energy: 0,
	};
};
const makeFlybot = ({ x, y, facing = 1 }) => {
	return {
		id: getID(),
		type: "flybot",
		x,
		y,
		width: 2 * 15,
		height: 2 * 18,
		facing,
		animationFrame: 0,
	};
};
const makeEyebot = ({ x, y, facing = 1, facingY = 0 }) => {
	return {
		id: getID(),
		type: "eyebot",
		x,
		y,
		width: 2 * 15,
		height: 2 * 18,
		facing,
		facingY,
		animationFrame: 0,
	};
};
const makeBin = ({ x, y, facing = 0, scaredy = false }) => {
	return {
		id: getID(),
		type: "bin",
		x,
		y,
		width: 2 * 15,
		height: 3 * 18,
		facing,
		scaredy,
		animationFrame: 0,
	};
};
const makeCrate = ({ x, y }) => {
	return {
		id: getID(),
		type: "crate",
		x,
		y,
		width: 3 * 15,
		height: 2 * 18,
	};
};
const makeFire = ({ x, y, on, switchID }) => {
	return {
		id: getID(),
		type: "fire",
		x,
		y,
		width: 4 * 15,
		height: 1 * 18,
		on,
		switchID,
		animationFrame: 0,
		fixed: true,
	};
};
const makeFan = ({ x, y, on, switchID }) => {
	return {
		id: getID(),
		type: "fan",
		x,
		y,
		width: 4 * 15,
		height: 1 * 18,
		on,
		switchID,
		animationFrame: 0,
		fixed: true,
	};
};
const makeLaser = ({ x, y, on, switchID, facing }) => {
	return {
		id: getID(),
		type: "laser",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		on,
		switchID,
		animationFrame: 0,
		facing,
		fixed: true,
	};
};
const makeSwitch = ({ x, y, on, switchID }) => {
	return {
		id: getID(),
		type: "switch",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		on,
		switchID,
		fixed: true,
	};
};
const makeTeleport = ({ x, y, teleportID }) => {
	return {
		id: getID(),
		type: "teleport",
		x,
		y,
		width: 4 * 15,
		height: 1 * 18,
		teleportID,
		fixed: true,
		timer: 0,
	};
};
const makeJump = ({ x, y, fixed }) => {
	return {
		id: getID(),
		type: "jump",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		animationFrame: 0,
		fixed,
	};
};
const makeShield = ({ x, y, used = false, fixed = true }) => {
	return {
		id: getID(),
		type: "shield",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		fixed,
		used,
	};
};
const makePipe = ({ x, y }) => {
	return {
		id: getID(),
		type: "pipe",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		timer: -1,
		fixed: true,
	};
};
const makeDroplet = ({ x, y }) => {
	return {
		id: getID(),
		type: "droplet",
		x,
		y,
		width: 2 * 15,
		height: 1 * 18,
		splashing: false,
		animationFrame: 0,
	};
};

// #endregion
//
// █████ █████ █████ █████    █████ █████ █████ █████ █████             ◢◼◤/            ◢◼◤/            ◢◼◤/
//   █   █     █       █      █     █   █ █     █     █               ◢◼◤/            ◢◼◤/            ◢◼◤/
//   █   █████ █████   █      █     █████ █████ █████ █████  ◥◼◣\   ◢◼◤/     ◥◼◣\   ◢◼◤/     ◥◼◣\   ◢◼◤/
//   █   █         █   █      █     █   █     █ █         █    ◥◼◣◢◼◤/         ◥◼◣◢◼◤/         ◥◼◣◢◼◤/
//   █   █████ █████   █      █████ █   █ █████ █████ █████      ◥◤/             ◥◤/             ◥◤/
//
// #region Test Cases

const tests = [
	{
		name: "Tippy Toast",
		expect: "to win",
	},
	{
		name: "Tight Squeeze Stairs",
		expect: "to win",
	},
	{
		name: "Shallow Steps",
		expect: "to win",
	},
	{
		name: "Don't Skate The Crate",
		expect: "to win",
	},
	{
		name: "Twixt Crates",
		expect: "to win",
	},
	{
		name: "Armor Farmer",
		expect: "to win",
	},
	{
		name: "Armor Harmer",
		expect: "to lose",
	},
	{
		name: "Out of the Frying Pan And Into The Fire (Murder)",
		expect: "to draw",
	},
	{
		name: "Out of the Frying Pan And Into The Fire (Vengeance)",
		expect: "to lose",
	},
	{
		name: "Once You Win, You Won",
		expect: "to win",
	},
	{
		name: "You'll Be Shocked!",
		expect: "to lose",
	},
	{
		name: "All-Off Offal",
		expect: "to win",
	},
	{
		name: "Switch Off At Edge Case",
		expect: "to win",
	},
	{
		name: "Scared Off",
		expect: "to lose",
	},
	{
		name: "Scared Off II Junkbot's Jowls",
		expect: "to win",
	},
	{
		name: "Jump Stair Case",
		expect: "to win",
	},
	{
		name: "Jump Around (bricks in place)",
		expect: "to win",
	},
	{
		name: "Jump Around (bricks out of place)",
		expect: "to draw",
	},
	{
		name: "Perpetual Motion Machine (Test)",
		expect: "to win",
	},
	{
		name: "Jump Up Just To Edge",
		expect: "to win",
	},
	{
		name: "Collide With Bins In Midair",
		expect: "to win",
	},
	{
		name: "Don't Get Stuck On Jump",
		expect: "to win",
	},
	{
		name: "Bounce Against Wall",
		expect: "to win",
	},
	{
		name: "Turning Shouldn't Jump",
		expect: "to win",
	},
	{
		name: "Portable Boost (Test)",
		expect: "to win",
	},
	{
		name: "Blocked Teleport",
		expect: "to lose",
	},
	{
		name: "Lasers Not Blocked By Water",
		expect: "to lose",
	},
	{
		name: "Lasers Blocked By Gearbots",
		expect: "to win",
	},
	{
		name: "Don't Step Up Onto Gearbot",
		expect: "to win",
	},
	{
		name: "Don't Walk Over Gearbot",
		expect: "to win",
	},
	{
		name: "Don't Step Down Onto Gearbot",
		expect: "to win",
	},
	{
		name: "Step Down Onto Falling Crate",
		expect: "to win", // maybe??
	},
	{
		name: "Don't Walk Over Bins",
		expect: "to win",
	},
	{
		name: "Don't Step Down Onto Bins",
		expect: "to win",
	},
	{
		name: "Death From Below",
		expect: "to lose",
	},
	{
		name: "Flying Death",
		expect: "to lose",
	},
	{
		name: "Turn Away from Climbbot I",
		expect: "to win",
	},
	{
		name: "Turn Away from Climbbot II",
		expect: "to win",
	},
	{
		name: "Crate Fall Onto Offset Blocks",
		expect: "to win",
	},
	{
		name: "Gearbot Fall Onto Offset Blocks",
		expect: "to lose",
	},
	{
		name: "Climbbot Fall Onto Offset Blocks",
		expect: "to lose",
	},
	{
		name: "Hunter-Killer Climbbot (Fall Onto Offset Blocks)",
		expect: "to lose", // test will probably need updating when implementing this new logic
	},
	{
		name: "Ally",
		expect: "to win",
	},
];

const routingTests = [
	{
		hash: "#junkbot2/levels/basement-1/descent",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: "descent",
			levelGroup: "basement-1",
			screen: SCREEN_LEVEL,
			canonicalHash: "#junkbot2/levels/basement-1/descent",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot2/levels/basement-1/descent/edit-mode",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: "descent",
			levelGroup: "basement-1",
			screen: SCREEN_LEVEL,
			canonicalHash: "#junkbot2/levels/basement-1/descent/edit",
			wantsEdit: true,
		},
	},
	{
		hash: "#junkbot/levels",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: undefined,
			// levelGroup: "building-1", // @TODO
			screen: SCREEN_LEVEL_SELECT,
			// canonicalHash: "#junkbot/levels/building-1", // @TODO
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot/building-1",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: undefined,
			levelGroup: "building-1",
			screen: SCREEN_LEVEL_SELECT,
			canonicalHash: "#junkbot/levels/building-1",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot2/levels/basement-2",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: "basement-2",
			screen: SCREEN_LEVEL_SELECT,
			canonicalHash: "#junkbot2/levels/basement-2",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot2/levels",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: "basement-1",
			screen: SCREEN_LEVEL_SELECT,
			canonicalHash: "#junkbot2/levels/basement-1",
			wantsEdit: false,
		},
	},
	{
		hash: "#level-editor",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#level-editor",
			wantsEdit: true,
		},
	},
	{
		hash: "#",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot",
			wantsEdit: false,
		},
	},
	{
		hash: "#JUNKBOT2",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot2",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot-undercover",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot2",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot-uc",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot2",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot-1",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot",
			wantsEdit: false,
		},
	},
	{
		hash: "#junkbot-2",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_TITLE,
			canonicalHash: "#junkbot2",
			wantsEdit: false,
		},
	},
	{
		hash: "#tests/tippy-toast",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: "tippy-toast",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests/tippy-toast",
			wantsEdit: false,
		},
	},
	{
		hash: "#tests/levels/armor-farmer",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: "armor-farmer",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests/armor-farmer",
			wantsEdit: false,
		},
	},
	{
		hash: "#tests/armor-farmer/edit",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: "armor-farmer",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests/armor-farmer/edit",
			wantsEdit: true,
		},
	},
	{
		hash: "#tests/levels/armor-farmer/edit",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: "armor-farmer",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests/armor-farmer/edit",
			wantsEdit: true,
		},
	},
	// @TODO maybe
	// {
	// 	hash: "#descent",
	// 	expected: {
	// 		game: GAME_JUNKBOT_UNDERCOVER,
	// 		levelSlug: "descent",
	// 		levelGroup: undefined,
	// 		screen: SCREEN_TITLE,
	// 		canonicalHash: "#junkbot2/levels/basement-1/descent",
	// 		wantsEdit: false,
	// 	},
	// },
	{
		hash: "#local/levels/custom-level",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: "custom-level",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			// canonicalHash: "#my-computer-NOT_A_SHARABLE_LINK/levels/custom-level", // @TODO maybe rename to clarify these URLs aren't sharable
			canonicalHash: "#local/levels/custom-level",
			wantsEdit: false,
		},
	},
	{
		hash: "#local/levels/custom-level/edit",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: "custom-level",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#local/levels/custom-level/edit",
			wantsEdit: true,
		},
	},
	{
		hash: "#local/levels/page-1/art-in-the-lobby-1",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: "art-in-the-lobby-1",
			levelGroup: undefined, // ignore/strip "page-1" since it's an un-paginated listing
			screen: SCREEN_LEVEL,
			canonicalHash: "#local/levels/art-in-the-lobby-1",
			wantsEdit: false,
		},
	},
	{
		hash: "#local/levels/",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_LEVEL_SELECT,
			canonicalHash: "#local/levels",
			wantsEdit: false,
		},
	},
	// edge case: if you name a level "edit", "/edit" should be treated as the level name, not the edit mode
	{
		hash: "#local/levels/edit",
		expected: {
			game: GAME_USER_CREATED,
			levelSlug: "edit",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#local/levels/edit",
			wantsEdit: false,
		},
	},
	// hypothetical edge case: if there were a built-in level called "edit"
	{
		hash: "#junkbot2/levels/basement-1/edit",
		expected: {
			game: GAME_JUNKBOT_UNDERCOVER,
			levelSlug: "edit",
			levelGroup: "basement-1",
			screen: SCREEN_LEVEL,
			canonicalHash: "#junkbot2/levels/basement-1/edit",
			wantsEdit: false,
		},
	},
	// Old routes:
	{
		hash: "#run-tests",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: undefined,
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests",
			wantsEdit: false,
		},
	},
	{
		hash: "#level=Junkbot;new-employee-training",
		expected: {
			game: GAME_JUNKBOT,
			levelSlug: "new-employee-training",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#junkbot/levels/new-employee-training",
			wantsEdit: false,
		},
	},
	{
		hash: "#level=Test%20Cases;Tippy%20Toast",
		expected: {
			game: GAME_TEST_CASES,
			levelSlug: "tippy-toast",
			levelGroup: undefined,
			screen: SCREEN_LEVEL,
			canonicalHash: "#tests/tippy-toast",
			wantsEdit: false,
		},
	},
	// @TODO
	// {
	// 	hash: "#level=local;Custom%20Level",
	// 	expected: {
	// 		game: GAME_USER_CREATED,
	// 		levelSlug: "custom-level",
	// 		levelGroup: undefined,
	// 		screen: SCREEN_LEVEL,
	// 		canonicalHash: "#my-computer/levels/custom-level",
	// 		wantsEdit: false,
	// 	},
	// },
];

// #endregion
//
// █████ █████ █████ █████ █     █████ █████ █████ █████ ███ █████ █   █ ------ --- -
// █   █ █     █     █     █     █     █   █ █   █   █    █  █   █ ██  █ ------
// █████ █     █     █████ █     █████ █████ █████   █    █  █   █ █ █ █ ---- -----
// █   █ █     █     █     █     █     █  █  █   █   █    █  █   █ █  ██ ----
// █   █ █████ █████ █████ █████ █████ █  ██ █   █   █   ███ █████ █   █ -------
//
// ___________________________  _____________________  __________________________
// __  ___/__  __/__  __ \_  / / /_  ____/__  __/_  / / /__  __ \__  ____/_  ___/
// _____ \__  /  __  /_/ /  / / /_  /    __  /  _  / / /__  /_/ /_  __/  _____ \
// ____/ /_  /   _  _, _// /_/ / / /___  _  /   / /_/ / _  _, _/_  /___  ____/ /
// /____/ /_/    /_/ |_| \____/  \____/  /_/    \____/  /_/ |_| /_____/  /____/
//
// #region Acceleration Structures

let entitiesByTopY = {}; // y to array of entities with that y as their top
let entitiesByBottomY = {}; // y to array of entities with that y as their bottom
let lastKeys = new Map(); // ancillary structure for updating the by-y structures - entity to {topY, bottomY}

const entityMoved = (entity) => {
	const yKeys = lastKeys.get(entity) || {};
	entitiesByTopY[entity.y] = entitiesByTopY[entity.y] || [];
	entitiesByBottomY[entity.y + entity.height] = entitiesByBottomY[entity.y + entity.height] || [];
	if (yKeys.topY) {
		arrayRemove(entitiesByTopY[yKeys.topY], entity);
	}
	if (yKeys.bottomY) {
		arrayRemove(entitiesByBottomY[yKeys.bottomY], entity);
	}
	yKeys.topY = entity.y;
	yKeys.bottomY = entity.y + entity.height;
	entitiesByTopY[yKeys.topY].push(entity);
	entitiesByBottomY[yKeys.bottomY].push(entity);
	lastKeys.set(entity, yKeys);
};

const updateAccelerationStructures = () => {
	// add new entities to acceleration structures
	for (const entity of entities) {
		if (!lastKeys.has(entity)) {
			entityMoved(entity);
		}
	}
	// clean up acceleration structures
	for (const [entity, yKeys] of lastKeys.entries()) {
		if (entities.indexOf(entity) === -1) {
			if (yKeys.topY) {
				arrayRemove(entitiesByTopY[yKeys.topY], entity);
			}
			if (yKeys.bottomY) {
				arrayRemove(entitiesByBottomY[yKeys.bottomY], entity);
			}
			lastKeys.delete(entity);
		}
	}
	const cleanByYObj = (entitiesByY) => {
		for (const y of Object.keys(entitiesByY)) {
			if (entitiesByY[y].length === 0) {
				delete entitiesByY[y];
			}
		}
	};
	cleanByYObj(entitiesByTopY);
	cleanByYObj(entitiesByBottomY);
};

// #endregion
//                                                ___________
//                                               '._==_==_=_.'
//  █ █  █ █ █ ███ █   █ █   █ ███ █   █ █████  .-(::      +)-.
// █████ █ █ █  █  ██  █ ██  █  █  ██  █ █       \_\::.    /_/
//  █ █  █ █ █  █  █ █ █ █ █ █  █  █ █ █ █ ███      '::. .'
// █████ █ █ █  █  █  ██ █  ██  █  █  ██ █   █        ) (
//  █ █   █ █  ███ █   █ █   █ ███ █   █ █████      _.' '._
//                                                 `"""""""`
// #region Win/Lose (#winning)

const winOrLose = () => {
	// Cases:
	// ("while collecting" and "dying" refer to playing the animations)
	// - Alive while collecting last bin: "" (winING, probably)
	// - Dying while collecting last bin: "" (losING)
	// - Dead while collecting last bin: "lose" (shouldn't happen maybe though, if collectingBin is reset)
	// - Alive after collecting last bin: "win"
	// - Dying after collecting last bin: (should already be "win" and paused)
	// - Dead after collecting last bin: "lose"
	// - Dead, bins left to collect: "lose"
	// - Dying, bins left to collect: "" (losING)
	// - Alive, bins left to collect: "" (normal state)
	if (entities.some((entity) => entity.type === "junkbot" && !entity.dead)) {
		if (
			entities.some((entity) => entity.type === "junkbot" && !entity.dead && !entity.dying) &&
			!entities.some((entity) => entity.type === "bin") &&
			entities.every((entity) => !entity.collectingBin)
		) {
			return "win";
		} else {
			return "";
		}
	} else {
		return "lose";
	}
};

// #endregion
//
// █████ █████ █████ ███ █████ █     ███ █████ █████ █████ ███ █████ █   █    __<>__   ________   ___         [{
// █     █     █   █  █  █   █ █      █     █  █   █   █    █  █   █ ██  █   | /__\ | |  |__|  | /...\_______   type: "junkbot", id: 1,
// █████ █████ █████  █  █████ █      █    █   █████   █    █  █   █ █ █ █   | [{}, | |   ()   | |  /       /   x: 0, y: 0, width: 30, height: 72,
//     █ █     █  █   █  █   █ █      █   █    █   █   █    █  █   █ █  ██   |  {}] | | .----. | | /       /    animationFrame: 0, facing: 1,
// █████ █████ █  ██ ███ █   █ █████ ███ █████ █   █   █   ███ █████ █   █   |______| |_|____|_| |/_______/   }]
//
// #region Serialization (@TODO: bring deserialization together with serialization)

const serializeToJSON = (level) => {
	return JSON.stringify({ version: 0.3, format: "janitorial-android", level }, (name, value) => {
		if (name === "grabbed" || name === "grabOffset") {
			return undefined;
		}
		return value;
	}, "\t");
};

const serializeLevel = (level) => {
	// let text = [];
	// const addSection = (name, keyValuePairs) => {
	// 	text += `[${name}]\n`;
	// 	for (const [key, value] of keyValuePairs) {
	// 		text += `${key}=${value}`;
	// 	}
	// 	text += "\n";
	// };
	// addSection("info", [
	// 	["", ""]
	// ]);
	const types = [];
	const unknownTypeMappings = [];
	const parts = [];
	for (const entity of level.entities) {
		let type;
		if (entity.type === "brick") {
			type = `brick_${String(entity.widthInStuds).padStart(2, "0")}`;
		} else if (entity.type === "jump") {
			type = `${entity.fixed ? "haz" : "brick"}_slickjump`;
		} else if (entity.type === "shield") {
			type = `${entity.fixed ? "haz" : "brick"}_slickshield`;
		} else if (entity.type === "laser") {
			// entity name is confusing in regard to direction
			type = `haz_slicklaser_${entity.facing === -1 ? "r" : "l"}`;
		} else if (entity.type === "bin" && entity.scaredy) {
			type = "scaredy";
		} else {
			type = {
				junkbot: "minifig",
				gearbot: "haz_walker",
				climbbot: "haz_climber",
				flybot: "haz_dumbfloat",
				eyebot: "haz_float",
				bin: "flag",
				crate: "haz_slickcrate",
				fire: "haz_slickfire",
				fan: "haz_slickfan",
				switch: "haz_slickswitch",
				teleport: "haz_slickteleport",
				pipe: "haz_slickpipe",
				droplet: "haz_droplet", // made up / unofficial
			}[entity.type];
		}
		if (type) {
			if (types.indexOf(type) === -1) {
				types.push(type);
			}
			// [0] - x coordinate
			// [1] - y coordinate
			// [2] - type index (in the types array)
			// [3] - color index (in the colors array)
			// [4] - starting animation name (0 for objects that don't animate)
			// [5] - starting animation frame ? (this seems to always be 1 for any animated object)
			// [6] - object relation ID, either a teleport or a switch; two teleports can reference each other with the same ID
			const gridX = entity.x / 15 + 1;
			const gridY = (entity.y + entity.height) / 18;
			const typeIndex = types.indexOf(type);
			const colorIndex = brickColorNames.indexOf(entity.colorName || "red");
			let animationName;
			if ("on" in entity) {
				animationName = entity.on ? "on" : "off";
			} else if (entity.type === "eyebot") {
				animationName = "inactive";
			} else if (entity.type === "flybot") {
				if (entity.facingY === -1) {
					animationName = "U";
				} else if (entity.facingY === 1) {
					animationName = "D";
				} else if (entity.facing === -1) {
					animationName = "L";
				} else {
					animationName = "R";
				}
			} else if (entity.type === "bin" && entity.scaredy) {
				animationName = "rest";
			} else if (("facing" in entity) && entity.type !== "bin") {
				animationName = entity.facing > 0 ? "walk_r" : "walk_l";
			} else if (entity.type === "jump") {
				animationName = "dormant";
			} else if (entity.type === "pipe") {
				animationName = "dry";
			} else if (entity.type === "crate") {
				animationName = "norm";
			} else {
				animationName = "";
			}
			parts.push(`${gridX};${gridY};${typeIndex + 1};${colorIndex + 1};${animationName};${entity.animationFrame || 1};${entity.switchID || entity.teleportID || ""}`);
		} else {
			unknownTypeMappings.push(entity.type);
		}
	}
	if (unknownTypeMappings.length) {
		showErrorMessage(`Unknown type mappings for entity types:\n\n${unknownTypeMappings.join("\n")}`);
	}
	const stringifyDecals = (decals = []) => decals.map(({ x, y, name }) => `${x};${y};${name}`).join(",");
	return `[info]
title=${level.title || "Saved World"}
par=${isFinite(level.par) ? level.par : 10000}
hint=${level.hint || ""}

[playfield]
${level.bounds ? `size=${level.bounds.width / 15},${level.bounds.height / 18}` : ""}
spacing=15,18
scale=1

[background]
backdrop=${level.backdropName || "bkg1"}
decals=${stringifyDecals(level.decals)}
bgdecals=${stringifyDecals(level.backgroundDecals)}

[partslist]
types=${types.join(",")}
colors=${brickColorNames.join(",")}
parts=${parts.join(",")}

`;
};
const resetAndInit = (level) => {
	currentLevel = level;
	entities = currentLevel.entities; // shortcut

	entitiesByTopY = {};
	entitiesByBottomY = {};
	lastKeys = new Map();
	dragging.length = 0;
	wind.length = 0;
	laserBeams.length = 0;
	teleportEffects.length = 0;
	playthroughEvents.length = 0;
	playbackEvents.length = 0;
	// playbackEvents = playthroughEvents; // for rewinding with negative rewind speed
	playbackLevel = {};
	levelLastFrame = {};
	// sort for consistency for level delta patching
	entities.sort((a, b) => a.id - b.id);
	playthroughEvents.push({
		type: "level",
		t: 0,
		levelPatch: diffPatcher.clone(diffPatcher.diff(playbackLevel, currentLevel)),
	});
	moves = 0;
	frameCounter = 0;
	desynchronized = false;
	idCounter = 0;
	for (const entity of entities) {
		delete entity.grabbed;
		delete entity.grabOffset;
		idCounter = Math.max(idCounter, (entity.id ?? 0) + 1);
	}
	for (const entity of entities) {
		// separate from the above loop to avoid ID collisions
		if (typeof entity.id !== "number") {
			entity.id = getID();
		}
	}
	winLoseState = winOrLose(); // in case there's no bins, don't say OH YEAH; and in case there's no junkbots, don't consider it a lose
	updateEditorUIForLevelChange(currentLevel);
};
// @TODO: make this pure, and use initLevel in cases where loading from a file, so undos/etc. are reset
const deserializeJSON = (json) => {
	const state = JSON.parse(json);
	if ("version" in state && state.version < 0.3) {
		state.level = { entities: state.entities };
	}
	resetAndInit(state.level);
};

// All entity name animation name pairs in the original Junkbot games' levels, normalized to lowercase
// brick_01:
// brick_01:0
// brick_02:
// brick_02:0
// brick_03:
// brick_03:0
// brick_04:
// brick_04:0
// brick_06:0
// brick_08:
// brick_08:0
// brick_slickjump:dormant
// brick_slickshield:on
// flag:
// flag:0
// flag:none
// haz_climber:walk_r
// haz_dumbfloat:l
// haz_float:inactive
// haz_slickcrate:norm
// haz_slickfan:none
// haz_slickfan:off
// haz_slickfan:on
// haz_slickfire:off
// haz_slickfire:on
// haz_slickjump:dormant
// haz_slicklaser_l:off
// haz_slicklaser_l:on
// haz_slicklaser_r:off
// haz_slicklaser_r:on
// haz_slickpipe:dry
// haz_slickshield:on
// haz_slickswitch:off
// haz_slickswitch:on
// haz_slickteleport:on
// haz_walker:walk_l
// minifig:walk_l
// minifig:walk_r
// scaredy:rest

const loadLevelFromText = (levelData, game) => {
	const sections = {};
	let sectionName = "";
	for (const line of levelData.split(/\r?\n/g)) {
		if (!line.match(/^\s*(#.*)?$/)) {
			const match = line.match(/^\[(.*)\]$/);
			if (match) {
				sectionName = match[1];
			} else {
				sections[sectionName] = sections[sectionName] || [];
				sections[sectionName].push(line.split("="));
			}
		}
	}

	const level = {
		title: "",
		hint: "",
		par: Infinity,
		backdropName: null,
		decals: [],
		backgroundDecals: [],
		entities: [],
		game,
		bounds: null,
	};

	if (sections.info) {
		for (const [key, value] of sections.info) {
			if (key.match(/^(title|hint)$/i)) {
				level[key] = value;
			} else if (key.match(/^par$/i)) {
				level.par = Number(value);
			}
		}
	}
	let spacing = [15, 18];
	if (sections.playfield) {
		for (const [key, value] of sections.playfield) {
			if (key.match(/^spacing$/i)) {
				spacing = value.split(",").map(Number);
			}
		}
		for (const [key, value] of sections.playfield) {
			if (key.match(/^size$/i)) {
				const size = value.split(",").map(Number);
				level.bounds = {
					x: 0,
					y: 0,
					width: size[0] * spacing[0],
					height: size[1] * spacing[1],
				};
			}
		}
	}
	if (sections.background) {
		const parseDecals = (value) => {
			if (value.indexOf(",") === -1) {
				return [];
			}
			return value.split(",").map((str) => {
				const [x, y, name] = str.split(";");
				return { x: Number(x), y: Number(y), name };
			});
		};
		for (const [key, value] of sections.background) {
			if (key.match(/^bgdecals$/i)) {
				level.backgroundDecals = level.backgroundDecals.concat(parseDecals(value));
			} else if (key.match(/^decals$/i)) {
				level.decals = level.decals.concat(parseDecals(value));
			} else if (key.match(/^backdrop$/i)) {
				level.backdropName = value;
			}
		}
	}

	let types = [];
	let colors = [];
	const { entities } = level;
	if (!sections.partslist) {
		throw new SyntaxError("No [partslist] section found.");
	}
	for (const [key, value] of sections.partslist) {
		if (key === "types") {
			types = types.concat(value.toLowerCase().split(","));
		} else if (key === "colors") {
			colors = colors.concat(value.toLowerCase().split(","));
		} else if (key === "parts") {
			for (const entityDef of value.split(",")) {
				const e = entityDef.split(";");
				// [0] - x coordinate
				// [1] - y coordinate
				// [2] - type index (in the types array)
				// [3] - color index (in the colors array)
				// [4] - starting animation name (0 for objects that don't animate)
				// [5] - starting animation frame ? (this seems to always be 1 for any animated object)
				// [6] - object relation ID, either a teleport or a switch; two teleports can reference each other with the same ID
				const x = (e[0] - 1) * spacing[0];
				const y = (e[1] - 1) * spacing[1];
				const typeName = types[e[2] - 1].toLowerCase();
				const colorName = colors[e[3] - 1].toLowerCase();
				const animationName = e[4].toLowerCase();
				const facing = animationName.match(/_L/i) ? -1 : 1;
				let facingY = 0;
				if (animationName.match(/_U/i)) {
					facingY = -1;
				} else if (animationName.match(/_D/i)) {
					facingY = 1;
				}
				const brickMatch = typeName.match(/brick_(\d+)/i);
				if (brickMatch) {
					entities.push(makeBrick({
						x, y, colorName, fixed: colorName === "gray", widthInStuds: parseInt(brickMatch[1], 10)
					}));
				} else if (typeName === "minifig") {
					entities.push(makeJunkbot({ x, y: y - 18 * 3, facing }));
				} else if (typeName === "haz_walker") {
					entities.push(makeGearbot({ x, y: y - 18 * 1, facing }));
				} else if (typeName === "haz_climber") {
					entities.push(makeClimbbot({ x, y: y - 18 * 1, facing, facingY }));
				} else if (typeName === "haz_dumbfloat") {
					entities.push(makeFlybot({ x, y: y - 18 * 1, facing }));
				} else if (typeName === "haz_float") {
					entities.push(makeEyebot({ x, y: y - 18 * 1, facing }));
				} else if (typeName === "flag") {
					entities.push(makeBin({ x, y: y - 18 * 2, facing }));
				} else if (typeName === "scaredy") {
					entities.push(makeBin({ x, y: y - 18 * 2, facing, scaredy: true }));
				} else if (typeName === "haz_slickcrate") {
					entities.push(makeCrate({ x, y: y - 18 }));
				} else if (typeName === "haz_slickfire") {
					entities.push(makeFire({ x, y, on: animationName === "on" || animationName === "none", switchID: e[6] }));
				} else if (typeName === "haz_slickfan") {
					entities.push(makeFan({ x, y, on: animationName === "on" || animationName === "none", switchID: e[6] }));
				} else if (typeName === "haz_slicklaser_l") {
					// entity name is confusing in regard to direction, haz_slicklaser_l points right in the game
					entities.push(makeLaser({ x, y, on: animationName === "on" || animationName === "none", switchID: e[6], facing: 1 }));
				} else if (typeName === "haz_slicklaser_r") {
					// entity name is confusing in regard to direction, haz_slicklaser_r points left in the game
					entities.push(makeLaser({ x, y, on: animationName === "on" || animationName === "none", switchID: e[6], facing: -1 }));
				} else if (typeName === "haz_slickswitch") {
					entities.push(makeSwitch({ x, y, on: animationName === "on" || animationName === "none", switchID: e[6] }));
				} else if (typeName === "haz_slickteleport") {
					entities.push(makeTeleport({ x, y, teleportID: e[6] }));
				} else if (typeName === "haz_slickjump") {
					entities.push(makeJump({ x, y, fixed: true }));
				} else if (typeName === "brick_slickjump") {
					entities.push(makeJump({ x, y, fixed: false }));
				} else if (typeName === "haz_slickshield") {
					entities.push(makeShield({ x, y, used: animationName === "off", fixed: true }));
				} else if (typeName === "brick_slickshield") {
					entities.push(makeShield({ x, y, used: animationName === "off", fixed: false }));
				} else if (typeName === "haz_slickpipe") {
					entities.push(makePipe({ x, y }));
				} else if (typeName === "haz_droplet") { // made up / unofficial
					entities.push(makeDroplet({ x, y }));
				} else {
					entities.push({ id: getID(), type: typeName, x, y, colorName, widthInStuds: 2, width: 2 * 15, height: 18, fixed: true });
				}
			}
		}
	}

	return level;
};

// #endregion
//
// █     █████ █████ ████  ███ █   █ █████
// █     █   █ █   █ █   █  █  ██  █ █        ╔════════════════════════════════════════════════╗
// █     █   █ █████ █   █  █  █ █ █ █ ███    ║▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░25%░░░░░░░░░░░░░░░░░░░░░░║
// █     █   █ █   █ █   █  █  █  ██ █   █    ╚════════════════════════════════════════════════╝
// █████ █████ █   █ ████  ███ █   █ █████                 (game already interactive)
//
// #region Loading

let resources;
// resources needed for the title screen
// ideally this could be split more cleanly (sprite sheets are big)
const hotResourcePaths = {
	sprites: "images/spritesheets/sprites.png",
	spritesAtlas: "images/spritesheets/sprites.json",
	backgrounds: "images/spritesheets/backgrounds.png",
	backgroundsAtlas: "images/spritesheets/backgrounds.json",
	junkbotAnimations: "junkbot-animations.json",
	font: "font/font.png",
	turn: "audio/sound-effects/turn1.ogg",
	blockPickUp: "audio/sound-effects/blockpickup.ogg",
	// blockPickUpFromAir: "audio/sound-effects/custom/pick-up-from-air.wav",
	blockDrop: "audio/sound-effects/blockdrop.ogg",
	blockClick: "audio/sound-effects/blockclick.ogg",
	buttonClick: "audio/sound-effects/h_button1.ogg",
	titleScreenLevel: "levels/custom/Title Screen.txt",
	titleScreenWelcomePanel: "images/menus/loading_bkg_frame.png",
};
const otherResourcePaths = {
	levelEditorDefaultLevel: "levels/custom/Level Editor Default Level.txt",
	// menus: "images/spritesheets/menus.png",
	// menusAtlas: "images/spritesheets/menus.json",
	spritesUndercover: "images/spritesheets/Undercover Exclusive/sprites.png",
	spritesUndercoverAtlas: "images/spritesheets/Undercover Exclusive/sprites.json",
	backgroundsUndercover: "images/spritesheets/Undercover Exclusive/backgrounds.png",
	backgroundsUndercoverAtlas: "images/spritesheets/Undercover Exclusive/backgrounds.json",
	// menusUndercover: "images/spritesheets/Undercover Exclusive/menus.png",
	// menusUndercoverAtlas: "images/spritesheets/Undercover Exclusive/menus.json",
	tabLocked: "audio/sound-effects/spring_1.ogg",
	tabSwitch: "audio/sound-effects/h_powerup3.ogg",
	enterLevel: "audio/sound-effects/enter_level.wav",
	fall: "audio/sound-effects/fall.ogg",
	headBonk: "audio/sound-effects/headbonk1.ogg",
	collectBin: "audio/sound-effects/eat1.ogg",
	collectBin2: "audio/sound-effects/garbage1.ogg",
	switchClick: "audio/sound-effects/switch_click.ogg",
	switchOn: "audio/sound-effects/switch_on.ogg",
	switchOff: "audio/sound-effects/switch_off.ogg",
	deathByFire: "audio/sound-effects/fire.ogg",
	deathByWater: "audio/sound-effects/electricity1.ogg",
	deathByLaser: "audio/sound-effects/undercover/laser_hit.wav",
	deathByBot: "audio/sound-effects/robottouch4.ogg",
	getShield: "audio/sound-effects/shieldon2.ogg",
	getPowerup: "audio/sound-effects/h_powerup1.ogg",
	losePowerup: "audio/sound-effects/h_powerdown3.ogg",
	teleport: "audio/sound-effects/undercover/teleport.wav",
	ohYeah: "audio/sound-effects/voice_ohyeah.ogg",
	ouch: "audio/sound-effects/voice_ouch.ogg",
	uhoh: "audio/sound-effects/voice_uhoh.ogg",
	jump: "audio/sound-effects/jump3.ogg",
	fan: "audio/sound-effects/fan.ogg",
	drip0: "audio/sound-effects/drip1.ogg",
	drip1: "audio/sound-effects/drip2.ogg",
	drip2: "audio/sound-effects/drip3.ogg",
	selectStart: "audio/sound-effects/custom/pick-up-from-air.wav",
	selectEnd: "audio/sound-effects/custom/select2.wav",
	delete: "audio/sound-effects/lego-creator/trash-I0514.wav",
	copyPaste: "audio/sound-effects/lego-creator/copy-I0510.wav",
	undo: "audio/sound-effects/lego-creator/undo-I0512.wav",
	redo: "audio/sound-effects/lego-creator/redo-I0513.wav",
	insert: "audio/sound-effects/lego-creator/insert-I0506.wav",
	rustle0: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML1.WAV",
	rustle1: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML2.WAV",
	rustle2: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML3.WAV",
	rustle3: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML4.WAV",
	rustle4: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML5.WAV",
	rustle5: "audio/sound-effects/lego-star-wars-force-awakens/LEGO_DEBRISSML6.WAV",
	levelNames: "levels/_LEVEL_LISTING.txt",
	levelNamesUndercover: "levels/Undercover Exclusive/_LEVEL_LISTING.txt",
};
const allResourcePaths = Object.fromEntries(Object.entries(hotResourcePaths).concat(Object.entries(otherResourcePaths)));
const numRustles = 6;
const numDrips = 3;
// Currently it is assumed only hot resources need derivatives.
const hotResourceDerivations = [
	(resources) => {
		// Monkey patch one frame of a sprite atlas (easier than regenerating the spritesheet)
		// eslint-disable-next-line camelcase
		resources.spritesAtlas.eyebot_active_1 = resources.spritesAtlas.eyebot_active_1fix;
	},
];
const deriveHotResources = (resources) => {
	for (const deriveFn of hotResourceDerivations) {
		deriveFn(resources);
	}
	return resources; // for promise chaining
};

const loadImage = (imagePath) => {
	const image = new Image();
	return new Promise((resolve, reject) => {
		image.onload = () => {
			resolve(image);
		};
		image.onerror = () => {
			reject(new Error(`Image failed to load ('${imagePath}')`));
		};
		image.src = imagePath;
	});
};

const loadJSON = async (path) => {
	const response = await fetch(path);
	if (response.ok) {
		return await response.json();
	} else {
		throw new Error(`got HTTP ${response.status} fetching '${path}'`);
	}
};

const loadAtlasJSON = async (path) => {
	const { frames, animations } = await loadJSON(path);
	const result = {};
	for (const [name, framesIndices] of Object.entries(animations)) {
		result[name.replace(/\.png/i, "")] = { bounds: frames[framesIndices[0]] };
	}
	return result;
};

const loadTextFile = async (path) => {
	const response = await fetch(path);
	if (response.ok) {
		return await response.text();
	} else {
		throw new Error(`got HTTP ${response.status} fetching '${path}'`);
	}
};

const loadLevelFromTextFile = async (path, game) => {
	game ??= path.match(/Undercover/i) ? GAME_JUNKBOT_UNDERCOVER : GAME_JUNKBOT;
	return loadLevelFromText(await loadTextFile(path), game);
};

const loadSound = async (path) => {
	const response = await fetch(path);
	if (response.ok) {
		return await audioCtx.decodeAudioData(await response.arrayBuffer());
	} else {
		throw new Error(`got HTTP ${response.status} fetching '${path}'`);
	}
};

const loadLevelListing = async (path) => {
	const text = await loadTextFile(path);
	return text.trim().split(/\r?\n/g)
		.map((line) => line.trim());
};

const loadResource = (path) => {
	if (path.match(/spritesheets\/.*\.json$/i)) {
		return loadAtlasJSON(path);
	} else if (path.match(/\.json$/i)) {
		return loadJSON(path);
	} else if (path.match(/level.listing\.txt$/i)) {
		return loadLevelListing(path);
	} else if (path.match(/levels\/.*\.txt$/i)) {
		return loadLevelFromTextFile(path);
	} else if (path.match(/\.(ogg|mp3|wav)$/i)) {
		return loadSound(path);
	} else if (path.match(/\.(png|jpe?g|gif)$/i)) {
		return loadImage(path);
	}
	throw new Error(`How should I load this? '${path}'`);
};

const numProgressBricks = 14;
const progressBricks = [];
const totalResources = Object.keys(allResourcePaths).length;
let loadedResources = 0;
// This function can load all resources or just the hot resource bundle, but progress
// will be indicated for the total set of resources.
const loadResources = async (resourcePathsByID) => {
	const entries = Object.entries(resourcePathsByID);
	let silenceErrors = false;
	return Object.fromEntries(await Promise.all(entries.map(async ([id, path]) => {
		let resource;
		try {
			resource = await loadResource(path);
		} catch (error) {
			if (!silenceErrors) {
				if (location.protocol === "file:") {
					// This case is handled only if there was an error, because
					// technically you can disable security features in your browser
					// to allow loading local files, but it's not recommended.
					showErrorMessage(`This page must be served by a web server,\nin order to load files needed for the game.`, error);
					silenceErrors = true;
				} else {
					showErrorMessage(`Failed to load resource '${path}'`, error);
					// allow further errors so you can know what specific resources failed
					// (a single dialog box with a list would be better, but this is easier)
				}
			}
		}
		loadedResources += 1;
		if (loadedResources / totalResources * numProgressBricks > progressBricks.length) {
			const progressBrick = document.createElement("div");
			progressBrick.classList.add("load-progress-brick");
			progressBricks.push(progressBrick);
			loadProgress.appendChild(progressBrick);
		}
		return [id, resource];
	})));
};
let hotResourcesLoadedPromise;
let allResourcesLoadedPromise;

// #endregion
//                                    _    .
// █████ █   █ ████  ███ █████      _/ | ,  \
// █   █ █   █ █   █  █  █   █    _/   |. \  \
// █████ █   █ █   █  █  █   █   (_    | ) | )
// █   █ █   █ █   █  █  █   █     \_  |' /  /
// █   █ █████ ████  ███ █████       \_| '  /
//                                         '
// #region Audio

const playSound = (soundName, playbackRate = 1, cutOffEndFraction = 0) => {
	const audioBuffer = resources[soundName];
	if (!audioBuffer) {
		throw new Error(`No AudioBuffer loaded for sound '${soundName}'`);
	}
	if (muted || audioCtx.state !== "running") {
		return;
	}
	const gain = audioCtx.createGain();
	const source = audioCtx.createBufferSource();
	source.buffer = audioBuffer;
	source.connect(gain);
	gain.connect(mainGain);
	source.playbackRate.value = playbackRate;
	if (cutOffEndFraction) {
		gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + audioBuffer.duration * (1 - cutOffEndFraction));
	}
	source.start(0);
};

// #endregion
//
// █████ █████ █   █ █████    █████ █████ █   █ ████  █████ █████ ███ █   █ █████
//   █   █      █ █    █      █   █ █     ██  █ █   █ █     █   █  █  ██  █ █
//   █   █████   █     █      █████ █████ █ █ █ █   █ █████ █████  █  █ █ █ █ ███
//   █   █      █ █    █      █  █  █     █  ██ █   █ █     █  █   █  █  ██ █   █
//   █   █████ █   █   █      █  ██ █████ █   █ ████  █████ █  ██ ███ █   █ █████
//    ____________________________________________________
//   /\__________________________________________________/`-.   ▀█▀ █▀▀ ▀▄ ▄▀ ▀█▀
//  |◖◗|________________________________________________<    ◖▶  █  █▀▀  ▄▀▄   █
//   \/__________________________________________________\,-'    ▀  ▀▀▀ ▀   ▀  ▀
// #region Text Rendering

const fontChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890?!(),':"-+.^@#$%*~\`&_=;|\\/<>[]{}☺�ÄÖÜẞ`;
const fontCharW = "555555553555555555555555553555555555512211133313553535_255311_55332233555555"
	.replace(/_/g, "")
	.split("")
	.map((digit) => Number(digit));
const fontCharX = [];
for (let x = 0, i = 0; i < fontChars.length; i++) {
	fontCharX.push(x);
	x += fontCharW[i] + 1;
}
const fontCharHeight = 5;
const fontCharToIndex = {};
for (const char of fontChars) {
	fontCharToIndex[char] = fontChars.indexOf(char);
}

const colorizeWhiteAlphaImage = (image, color) => {
	const canvas = document.createElement("canvas");
	const ctx = canvas.getContext("2d");
	canvas.width = image.width;
	canvas.height = image.height;
	ctx.imageSmoothingEnabled = false;
	ctx.drawImage(image, 0, 0);
	ctx.globalCompositeOperation = "source-atop";
	ctx.fillStyle = color;
	ctx.fillRect(0, 0, canvas.width, canvas.height);
	return canvas;
};
const fontColors = {
	blue: "#00009c",
	sand: "#d09810",
	orange: "#c07500",
	gray: "#606060",
	black: "#000000",
	white: "#ffffff",
};
const fontCanvases = {};

hotResourceDerivations.push((resources) => {
	// Generate colored font sprites
	for (const [colorName, color] of Object.entries(fontColors)) {
		fontCanvases[colorName] = colorizeWhiteAlphaImage(resources.font, color);
	}
});

const drawText = (ctx, text, startX, startY, colorName, bgColor = "rgba(0,0,0,0.5)", padding = true) => {
	const fontImage = fontCanvases[colorName];
	let x = startX;
	let y = startY;
	text = text.toUpperCase();
	if (padding) {
		text = ` ${text} `.replace(/\n/g, " \n ").replace(/\n\s*$/, "");
	}
	ctx.fillStyle = bgColor;
	for (const char of text) {
		let w = 0;
		let charIndex = -1;
		if (char === " ") {
			w = 6;
		} else if (char === "\t") {
			w = 6 * 4;
			ctx.fillRect(x - 1, y - 2, 2, fontCharHeight + 4);
		} else if (char === "\n") {
			x = startX;
			y += fontCharHeight + 4;
			if (y > canvas.height) {
				return; // optimization for lazily-implemented debug text
			}
		} else {
			charIndex = fontCharToIndex[char];
			// fallback glyph
			if (charIndex === -1) {
				charIndex = fontCharToIndex["�"]; // U+FFFD REPLACEMENT CHARACTER
			}
		}
		let advance = w;
		if (charIndex > -1) {
			w = fontCharW[charIndex];
			advance = w + 1;
		}
		ctx.fillRect(x - 1, y - 2, advance, fontCharHeight + 4);
		if (charIndex > -1) {
			ctx.drawImage(fontImage, fontCharX[charIndex], 0, w, fontCharHeight, x, y, w, fontCharHeight);
		}
		x += advance;
	}
};

// #endregion
//                                                                                                                           ___      __                     ___
// █████ █   █ █████ ███ █████ █   █    █████ █████ █   █ ████  █████ █████ ███ █   █ █████     ,m^^^^m,                    /<>/|    [🔥]                   /  /|
// █     ██  █   █    █    █   █   █    █   █ █     ██  █ █   █ █     █   █  █  ██  █ █         'w,,,,w'     _-_-_-_-__    [ON]/    ______       ___       |===|/\
// █████ █ █ █   █    █    █    █ █     █████ █████ █ █ █ █   █ █████ █████  █  █ █ █ █ ███      ┇ ━▶ ┇     /-_-_-_-_ /|           ///////      (WVv)      ║    \/\
// █     █  ██   █    █    █     █      █  █  █     █  ██ █   █ █     █  █   █  █  ██ █   █     'w.,,.w'   |_o__o__o_|/|           \____/       |↱↴ |      ║ ↱↴  \/|
// █████ █   █   █   ███   █     █      █  ██ █████ █   █ ████  █████ █  ██ ███ █   █ █████                |_o__o__O_|/                         (Ꝉ↲_)      ║ Ꝉ↲  |👀︎
//                                                                                                             ((((        |||                             ║_____|/
//                                                                                                             ))))       _|||__                           ║⊙) )
//        ████  █████ █████ █████ █     █████           █████ █████ █████ █████ █████ █████ █████              ((((      /(777)/                           ║‡| └┐
//  █     █   █ █     █     █   █ █     █         █     █     █     █     █     █       █   █                  ))))      \____/     ______    __       __  ║‡_]-┘
// ███    █   █ █████ █     █████ █     █████    ███    █████ █████ █████ █████ █       █   █████             _((((_               /|o o /|  [💀]     /_/|
//  █     █   █ █     █     █   █ █         █     █     █     █     █     █     █       █       █            /_(%)_/              |‾‾‾‾‾| |~~~~~~~~~c(|⚡︎|/
//        ████  █████ █████ █   █ █████ █████           █████ █     █     █████ █████   █   █████            \____/               |▂▂▂▂▂|/             ‾
//
// #region Entity Rendering + Decals + Effects

const drawSwitchConnection = (ctx, switchEntity, controlledEntity) => {
	const startX = switchEntity.x + switchEntity.width / 2;
	const startY = switchEntity.y + switchEntity.height * 0.8;
	const endX = controlledEntity.x + controlledEntity.width / 2;
	const endY = controlledEntity.y + controlledEntity.height * 0.8;
	const dist = Math.hypot(endX - startX, endY - startY);
	const controlPointX = (startX + endX) / 2;
	const controlPointY = (startY + endY) / 2 + 50 + dist * 0.2;
	ctx.beginPath();
	ctx.moveTo(startX, startY);
	ctx.quadraticCurveTo(controlPointX, controlPointY, endX, endY);
	ctx.lineCap = "round";
	ctx.strokeStyle = controlledEntity.on ? "#005500" : "#550000";
	ctx.lineWidth = 4;
	ctx.stroke();
	ctx.strokeStyle = controlledEntity.on ? "#00ff00" : "#ff0000";
	ctx.lineWidth = 3;
	ctx.stroke();
};

const drawTeleportConnection = (ctx, teleportA, teleportB) => {
	const startX = teleportA.x + teleportA.width / 2 + 4;
	const startY = teleportA.y - 4;
	const endX = teleportB.x + teleportB.width / 2 + 4;
	const endY = teleportB.y - 4;
	const dist = Math.hypot(endX - startX, endY - startY);
	const fraction = -0.1;
	const controlPointX1 = startX + (endX - startX) * fraction;
	const controlPointY1 = (startY + endY) / 2 - 100 - dist * 0.2;
	const controlPointX2 = endX - (endX - startX) * fraction;
	const controlPointY2 = (startY + endY) / 2 - 100 - dist * 0.2;
	ctx.beginPath();
	ctx.moveTo(startX, startY);
	ctx.bezierCurveTo(controlPointX1, controlPointY1, controlPointX2, controlPointY2, endX, endY);
	ctx.lineCap = "round";
	ctx.strokeStyle = "rgba(255, 255, 100, 0.2)";
	ctx.lineWidth = 10;
	ctx.stroke();
	ctx.lineWidth = 20;
	ctx.stroke();
	ctx.lineWidth = 30;
	ctx.stroke();
};

const drawDecal = (ctx, x, y, name, game) => {
	let atlas = resources[game === GAME_JUNKBOT_UNDERCOVER ? "backgroundsUndercoverAtlas" : "backgroundsAtlas"];
	let frame = atlas[name];
	if (!frame) {
		atlas = resources.backgroundsAtlas;
		frame = atlas[name];
	}
	const image = resources[atlas === resources.backgroundsUndercoverAtlas ? "backgroundsUndercover" : "backgrounds"];
	if (!frame) {
		if (showDebug) {
			drawText(ctx, `decal ${name} missing`, x, y, "sand");
		}
		return;
	}
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(image, left, top, width, height, x, y, width, height);
};

const drawBrick = (ctx, brick) => {
	const frame = resources.spritesAtlas[`brick_${(brick.colorName || "gray") === "gray" ? "immobile" : brick.colorName}_${brick.widthInStuds || 2}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, brick.x, brick.y + brick.height - height - 1, width, height);
};

const drawBin = (ctx, bin) => {
	let frame = resources.spritesAtlas.bin;
	let spritesheet = resources.sprites;
	let rotation = 0;
	if (bin.scaredy && (bin.facing !== 0 || editing)) {
		let frameIndex = bin.animationFrame % 2;
		if (editing) {
			rotation = 0;
			frameIndex = 1;
		} else {
			rotation = (Math.random() - 0.5) / 4; // covering up the fact that I don't have animation offset data (@TODO)
		}
		frame = resources.spritesUndercoverAtlas[`SCAREDY_${bin.facing === 1 ? "WALK_R" : "walk_l"}_${1 + frameIndex}_s3`];
		spritesheet = resources.spritesUndercover;
	}
	const [left, top, width, height] = frame.bounds;
	ctx.save();
	ctx.translate(bin.x + bin.width / 2, bin.y + bin.height / 2);
	ctx.rotate(rotation);
	ctx.translate(-bin.x - bin.width / 2, -bin.y - bin.height / 2);
	ctx.drawImage(spritesheet, left, top, width, height, bin.x + 4, bin.y + bin.height - height - 5, width, height);
	ctx.restore();
};

const drawCrate = (ctx, bin) => {
	const frame = resources.spritesUndercoverAtlas.HAZ_SLICKCRATE;
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.spritesUndercover, left, top, width, height, bin.x, bin.y + bin.height - height - 1, width, height);
};

const drawFire = (ctx, entity) => {
	const frameIndex = entity.on ? Math.floor(entity.animationFrame % 8 < 4 ? entity.animationFrame % 4 : 4 - (entity.animationFrame % 4)) : 0;
	const frame = resources.spritesAtlas[`haz_slickFire_${entity.on ? "on" : "off"}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x + 1, entity.y + entity.height - height - 4, width, height);
};

const drawFan = (ctx, entity) => {
	const frameIndex = entity.on ? Math.floor(entity.animationFrame % 4) : 0;
	const frame = resources.spritesAtlas[`haz_slickFan_${entity.on ? "on" : "off"}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x + 1, entity.y + entity.height - height - 4, width, height);
};

const drawWind = (ctx, fan, targetExtents) => {
	if (!fan.on) {
		return;
	}
	for (let i = 0, x = fan.x + 15; x < fan.x + fan.width - 15; i += 1, x += 15) {
		let extent = 0;
		for (let y = fan.y - 18; y > -200; y -= 18) {
			if (extent >= targetExtents[i]) {
				break;
			}
			extent += 1;
			const frameIndex = Math.floor(fan.animationFrame % 7);
			const frame = resources.spritesAtlas[`fanAir_1_${1 + frameIndex}`];
			const [left, top, width, height] = frame.bounds;
			ctx.drawImage(resources.sprites, left, top, width, height, x + 4, y - frameIndex * 2 + 8, width, height);
		}
	}
};

const drawLaserBeam = (ctx, laserBrick, targetExtent, hitWhat) => {
	if (!laserBrick.on) {
		return;
	}
	for (let extent = 0; extent < targetExtent; extent += 1) {
		const x = laserBrick.x +
			(laserBrick.facing === 1 ? laserBrick.width : -15) +
			15 * extent * laserBrick.facing;
		if (extent >= targetExtent) {
			break;
		}
		const frameIndex = Math.floor(laserBrick.animationFrame % 3);
		const frame = resources.spritesUndercoverAtlas[`laserbeam_1_${1 + frameIndex}`];
		// eslint-disable-next-line prefer-const
		let [left, top, width, height] = frame.bounds;
		if (extent === targetExtent - 1 && laserBrick.facing === 1 && hitWhat && hitWhat.type !== "bin") {
			// depth illusion: "go behind" things to the right
			width -= 5;
		}
		ctx.drawImage(resources.spritesUndercover, left, top, width, height, x + 4, laserBrick.y, width, height);
	}
};

const drawTeleportEffect = (ctx, leftX, bottomY, frameIndex) => {
	const frameName = `transEfx_${1 + frameIndex}`;
	const frame = resources.spritesUndercoverAtlas[frameName];
	const [left, top, width, height] = frame.bounds;
	const offsetX = 5;
	const offsetY = 2;
	ctx.globalAlpha = 0.5;
	ctx.drawImage(resources.spritesUndercover, left, top, width, height, leftX + offsetX, bottomY - height + offsetY, width, height);
	ctx.globalAlpha = 1;
	// @TODO: check timings and frame offsets, and the animation should start before junkbot teleports
};


const drawJump = (ctx, entity) => {
	let animName = "dormant";
	let animLength = 1;
	if (entity.active) {
		animName = "active";
		animLength = 5;
	}
	const frameIndex = Math.floor(entity.animationFrame % animLength);
	const frame = resources.spritesAtlas[`${entity.fixed ? "haz" : "brick"}_slickJump_${animName}_${frameIndex + 1}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};

const drawShield = (ctx, entity) => {
	const atlas = resources[entity.fixed ? "spritesAtlas" : "spritesUndercoverAtlas"];
	const image = resources[entity.fixed ? "sprites" : "spritesUndercover"];
	const frame = atlas[`${entity.fixed ? "HAZ" : "BRICK"}_SLICKSHIELD_${entity.used ? "OFF" : "ON"}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(image, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};

const drawLaser = (ctx, entity) => {
	// entity name and sprite name are confusing in regard to direction
	const frame = resources.spritesUndercoverAtlas[`haz_slickLaser_${entity.facing === 1 ? "L" : "R"}_ON_1`];
	const [left, top, width, height] = frame.bounds;
	const alignRight = entity.facing === -1;
	if (alignRight) {
		ctx.drawImage(resources.spritesUndercover, left, top, width, height, entity.x + entity.width - width + 11, entity.y + entity.height - 1 - height, width, height);
	} else {
		ctx.drawImage(resources.spritesUndercover, left, top, width, height, entity.x, entity.y + entity.height - 1 - height, width, height);
	}
};

const drawTeleport = (ctx, entity) => {
	const on = entity.timer === 0 && !entity.blocked;
	let frameName = `haz_slickTeleport_${on ? "on" : "off"}_1`;
	if (entity.timer > 30) {
		frameName = `haz_slickTeleport_active_${1 + (entity.timer % 2)}`;
	}
	const frame = resources.spritesUndercoverAtlas[frameName];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.spritesUndercover, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};

const drawSwitch = (ctx, entity) => {
	const frame = resources.spritesAtlas[`haz_slickSwitch_${entity.on ? "on" : "off"}_1`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};

const drawPipe = (ctx, entity) => {
	const wet = entity.timer <= 6 && entity.timer > -1; // < 7 would cause error if timer is non-integer
	const frameIndex = Math.floor(wet ? 6 - entity.timer : 0);
	// if (wet) {
	// 	console.log("entity.timer", entity.timer, "frameIndex", frameIndex);
	// }
	const frame = resources.spritesAtlas[`haz_slickPipe_${wet ? "wet" : "dry"}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x + 11, entity.y - 12, width, height);
	if (showDebug) {
		drawText(ctx, String(entity.timer), entity.x, entity.y + entity.height + 5, "white");
	}
};

const drawDroplet = (ctx, entity) => {
	const frameIndex = Math.floor(entity.splashing ? entity.animationFrame : 0);
	const frame = resources.spritesAtlas[`drip_${entity.splashing ? "splashing" : "falling"}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	// ctx.drawImage(resources.sprites, left, top, width, height, entity.x + 15, entity.y, width, height);
	// @TODO: proper frame offsets (this is an approximation)
	const offsetX = (-3 - entity.animationFrame) * entity.splashing;
	const offsetY = (-15) * entity.splashing;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x + 15 + offsetX, entity.y + offsetY, width, height);
};

const drawGearbot = (ctx, entity) => {
	const frameIndex = Math.floor(entity.animationFrame % 2);
	const frame = resources.spritesAtlas[`gearbot_walk_${entity.facing === 1 ? "r" : "l"}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};
const drawClimbbot = (ctx, entity) => {
	const frameIndex = Math.floor(entity.animationFrame % 6);
	let direction = entity.facing === 1 ? "r" : "l";
	if (entity.facingY === -1) {
		direction = "u";
	} else if (entity.facingY === 1) {
		direction = "d";
	}
	const frame = resources.spritesAtlas[`climbbot_walk_${direction}_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y - 6, width, height);
};
const drawFlybot = (ctx, entity) => {
	const frameIndex = Math.floor(entity.animationFrame % 2);
	const frame = resources.spritesAtlas[`flybot_${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};
const drawEyebot = (ctx, entity) => {
	const frameIndex = Math.floor(entity.animationFrame % 2);
	const frame = resources.spritesAtlas[`eyebot_${(entity.activeTimer > 0) ? "active_" : ""}${1 + frameIndex}`];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(resources.sprites, left, top, width, height, entity.x, entity.y + entity.height - height - 1, width, height);
};

const drawJunkbot = (ctx, junkbot) => {
	let animName;
	let animLength = 10; // should be always set later
	if (junkbot.dead) {
		animName = "dead";
	} else if (junkbot.dyingFromWater) {
		animName = "water_die";
	} else if (junkbot.dying) {
		animName = "die";
	} else if (junkbot.collectingBin) {
		animName = "eat_start";
		animLength = 17;
	} else if (junkbot.gettingShield) {
		animName = `shield_on_${junkbot.facing === 1 ? "r" : "l"}`;
		animLength = 11;
	} else {
		animName = `walk_${junkbot.facing === 1 ? "r" : "l"}`;
	}
	if (junkbot.armored && (!junkbot.losingShield || (junkbot.animationFrame % 4 < 2))) {
		if (animName === "eat_start") {
			animName = "shield_eat";
		} else if (!animName.includes("shield")) {
			animName = `shield_${animName}`;
		}
	}
	const animation = resources.junkbotAnimations[animName];
	let frameName;
	let offset = { x: 0, y: 0 };
	if (animation) {
		animLength = animation.length;
		const t = Math.floor(junkbot.animationFrame % animLength);
		const keyFrame = animation[t];
		offset = keyFrame.offset;
		frameName = keyFrame.sprite;
		if (junkbot.isPreviewEntity && offset.x >= 5) {
			offset = { x: 5, y: offset.y };
		}
	} else {
		const t = Math.floor(junkbot.animationFrame % animLength);
		frameName = animName === "dead" ? "minifig_dead" : `minifig_${animName}_${1 + t}`;
	}
	const frame = resources.spritesAtlas[frameName];
	const [left, top, width, height] = frame.bounds;
	ctx.drawImage(
		resources.sprites,
		left,
		top,
		width,
		height,
		junkbot.x - offset.x,
		junkbot.y + junkbot.height - 1 - height - offset.y,
		width,
		height
	);
	// if (showDebug && !editing) {
	// 	// drawText(ctx, frameName, junkbot.x, junkbot.y + 20, "white");
	// 	drawText(ctx, `momentum:\n${junkbot.momentumX}, ${junkbot.momentumY}`, junkbot.x, junkbot.y + 20, "white");
	// }
};

const selectionHilightCanvases = {};
const renderSelectionHilight = (width, height, depth = 10, studsOnTop = false) => {
	const key = `${width}x${height}x${depth} studsOnTop=${studsOnTop}`;
	if (selectionHilightCanvases[key]) {
		return selectionHilightCanvases[key];
	}
	const canvas = document.createElement("canvas");
	canvas.width = width + depth + 1;
	canvas.height = height + depth + 1;
	const ctx = canvas.getContext("2d");
	ctx.fillStyle = "aqua";

	ctx.translate(depth, 0);
	for (let z = 0; z <= 10; z++) {
		if (z === 0 || z === 10) {
			for (const x of [0, 0 + width]) {
				ctx.fillRect(x, 0, 1, height + 1);
			}
			for (const y of [0, 0 + height]) {
				ctx.fillRect(0, y, width + 1, 1);
			}
		} else {
			for (const x of [0, 0 + width]) {
				for (const y of [0, 0 + height]) {
					ctx.fillRect(x, y, 1, 1);
				}
			}
			ctx.clearRect(1, 0, width - 1, 1);
			ctx.clearRect(width, 1, 1, height - 1);
		}
		ctx.translate(-1, 1);
	}
	ctx.clearRect(2, 0, width - 1, height - 1);
	if (studsOnTop) {
		for (let z = 0; z < width; z += 6) {
			for (let x = 0; x < width; x += 15) {
				ctx.clearRect(x + 6 + z, -7 - z, 11, 5);
			}
		}
	}

	selectionHilightCanvases[key] = canvas;
	return canvas;
};
const drawSelectionHilight = (ctx, x, y, width, height, depth = 10, studsOnTop = false) => {
	const image = renderSelectionHilight(width, height, depth, studsOnTop);
	ctx.save();
	ctx.translate(0, -2 - depth);
	ctx.drawImage(image, x, y);
	ctx.restore();
};

const drawEntity = (ctx, entity, hilight) => {
	switch (entity.type) {
		case "brick":
			drawBrick(ctx, entity);
			break;
		case "junkbot":
			// aJunkbot = entity;
			drawJunkbot(ctx, entity);
			break;
		case "gearbot":
			drawGearbot(ctx, entity);
			break;
		case "climbbot":
			drawClimbbot(ctx, entity);
			break;
		case "flybot":
			drawFlybot(ctx, entity);
			break;
		case "eyebot":
			drawEyebot(ctx, entity);
			break;
		case "bin":
			drawBin(ctx, entity);
			break;
		case "crate":
			drawCrate(ctx, entity);
			break;
		case "fire":
			drawFire(ctx, entity);
			break;
		case "fan":
			drawFan(ctx, entity);
			break;
		case "laser":
			drawLaser(ctx, entity);
			break;
		case "teleport":
			drawTeleport(ctx, entity);
			break;
		case "jump":
			drawJump(ctx, entity);
			break;
		case "pipe":
			drawPipe(ctx, entity);
			break;
		case "droplet":
			drawDroplet(ctx, entity);
			break;
		case "shield":
			drawShield(ctx, entity);
			break;
		case "switch":
			drawSwitch(ctx, entity);
			break;
		default:
			drawBrick(ctx, entity);
			drawText(ctx, entity.type, entity.x, entity.y, "white");
			break;
	}
	if (hilight) {
		ctx.save();
		ctx.globalAlpha = 0.5;
		drawSelectionHilight(ctx, entity.x, entity.y, entity.width, entity.height, 10, entity.type === "brick");
		ctx.restore();
	}
};

// #endregion
//
// █████ ████  ███ █████ █████ █████    █████ █   █ █   █ █████ █████ ███ █████ █   █ █████
// █     █   █  █    █   █   █ █   █    █     █   █ ██  █ █       █    █  █   █ ██  █ █
// █████ █   █  █    █   █   █ █████    █████ █   █ █ █ █ █       █    █  █   █ █ █ █ █████
// █     █   █  █    █   █   █ █  █     █     █   █ █  ██ █       █    █  █   █ █  ██     █ █
// █████ ████  ███   █   █████ █  ██    █     █████ █   █ █████   █   ███ █████ █   █ █████ █
//
//                                                                                                       _    .
// █   █ █████ █████ █████ █     █   █   _____     ___          ________   __<>__    _  _   ____       _/ | ,  \
// ██ ██ █   █ █       █   █     █   █  |    |_\  /...\_______ |  |__|  | | /__\ |  (()()) |  __|_   _/   |. \  \
// █ █ █ █   █ █████   █   █      █ █   |      |  |  /       / |   ()   | | ---. |   //\\  | |    | (_    | ) | )
// █   █ █   █     █   █   █       █    |      |  | /       /  | .----. | | -.-- |  //  \\ |_|    |   \_  |' /  /
// █   █ █████ █████   █   █████   █    |______|  |/_______/   |_|____|_| |______| |/    \|  |____|     \_| '  /
//                                                                                                            '
// #region Editor Functions, Mostly

const initLevel = (level) => {
	level = diffPatcher.clone(level); // matters for title screen's "reset screen" button
	editorLevelState = serializeToJSON(level);
	resetAndInit(level);
	viewport.centerX = 35 / 2 * 15;
	viewport.centerY = 24 / 2 * 15;
	undos.length = 0;
	redos.length = 0;
};
const loadLevelByName = ({ levelName, game }) => {
	const slug = levelNameToSlug(levelName);
	let fileName;
	// Level files are not named as URL slugs, so we can't just use `${slug}.txt`;
	// we have to find the file name by iterating through the known levels.
	for (const list of getLevelLists(resources)) {
		if (list.game === game || !game) {
			for (const listedLevelName of list.levelNames) {
				if (levelNameToSlug(listedLevelName) === slug) {
					fileName = `${listedLevelName.replace(/[:?]/g, "")}.txt`;
				}
			}
		}
	}
	if (!fileName) {
		throw new Error(`Could not find level file for level name "${levelName}"`);
	}
	let folder = {
		[GAME_JUNKBOT_UNDERCOVER]: "levels/Undercover Exclusive",
		[GAME_JUNKBOT]: "levels",
		[GAME_TEST_CASES]: "levels/test-cases",
	}[game];
	if (slug === "new-employee-training") {
		folder = "levels/custom";
		fileName = "New Employee Training (1j01).txt";
	}
	return loadLevelFromTextFile(`${folder}/${fileName}`, game);
};

const save = () => {
	if (editing) {
		editorLevelState = serializeToJSON(currentLevel);
		if (!currentLevel.title) {
			currentLevel.title = "Custom Level";
		}
		try {
			const { game, levelSlug } = parseRoute(location.hash);
			// console.log({ game, levelSlug }, "vs", currentLevel.game, levelNameToSlug(currentLevel.title));
			if (levelSlug !== levelNameToSlug(currentLevel.title) || game !== GAME_USER_CREATED) {
				const originalTitle = currentLevel.title.replace(/\s\(\d+\)$/, "");
				for (let n = 1; n < 100 && localStorage[storageKeys.level(currentLevel.title)]; n++) {
					currentLevel.title = `${originalTitle} (${n})`;
				}
				editorLevelState = serializeToJSON(currentLevel); // for title update
				localStorage[storageKeys.level(currentLevel.title)] = editorLevelState;
				// use pushState instead of setting location.hash so that undo history is preserved
				history.pushState(null, null, `#local/levels/${levelNameToSlug(currentLevel.title)}/edit`);
				updateEditorUIForLevelChange(currentLevel); // for title update
			} else {
				localStorage[storageKeys.level(currentLevel.title)] = editorLevelState;
			}
		} catch (error) {
			showErrorMessage("Couldn't save level.\nAllow local storage (sometimes called 'cookies') to enable autosave.", error);
		}
	}
};

const toggleShowDebug = () => {
	showDebug = !showDebug;
	try {
		localStorage[storageKeys.showDebug] = showDebug;
	} catch (error) {
		// no problem
	}
};
const updateMuteButton = () => {
	toggleMuteButton.ariaPressed = muted;
	const volume = mainGain.gain.value;
	const icon = toggleMuteButton.querySelector(".sprited-icon");
	let iconIndex;
	if (muted) {
		iconIndex = 21; // muted
	} else if (volume < 0.3) {
		iconIndex = 22; // volume-low
	} else if (volume < 0.6) {
		iconIndex = 23; // volume-medium
	} else {
		iconIndex = 24; // volume-high
	}
	icon.style.setProperty("--icon-index", iconIndex);
};
const toggleMute = ({ savePreference = true } = {}) => {
	muted = !muted;
	updateMuteButton();
	try {
		if (savePreference) {
			localStorage[storageKeys.muteSoundEffects] = muted;
		}
	} catch (error) {
		// that's okay
	}
	if (muted) {
		audioCtx.suspend();
	} else {
		audioCtx.resume();
	}
};
const setVolume = (volume) => {
	if (muted) {
		toggleMute();
	}
	mainGain.gain.value = volume;
	updateMuteButton();
	try {
		localStorage[storageKeys.volume] = volume;
	} catch (error) {
		// no big deal
	}
};
const togglePause = () => {
	paused = !paused;
	// if (editing && !paused) {
	// if (editing !== paused) {
	// 	toggleEditing();
	// }
};
const updateEditingButton = () => {
	toggleEditingButton.ariaPressed = editing;
	toggleEditingButton.querySelector("img").src = editing ? "images/icons/toggle-editing-edit-mode.png" : "images/icons/toggle-editing-play-mode.png";
};
const toggleEditing = () => {
	if (!editing && parseRoute(location.hash).screen !== SCREEN_LEVEL) {
		// @TODO: navigate, in order to edit the title screen level (ideally)
		return;
	}
	editing = !editing;
	editorUI.hidden = !editing;
	editorControlsBar.hidden = !editing;
	closeNonErrorDialogs();
	updateEditingButton();
	if (editing) {
		// eslint-disable-next-line no-use-before-define
		initEditorUI();

		// don't reset undos/redos, just reset the level
		// also, this may be undefined if loading from hash like #some/level/edit
		if (editorLevelState) {
			deserializeJSON(editorLevelState);
		}
	}
	if (editing !== paused) {
		togglePause();
	}

	if (parseRoute(location.hash).wantsEdit !== editing) {
		let newHash;
		if (editing) {
			// @TODO: if levelName not in URL, add it ("#tests/edit" is weird and broken)
			newHash = `${location.hash}/edit`;
		} else {
			newHash = location.hash.replace(/(^#?\/?edit\/)|(\/edit$)/, "");
		}
		// replaceState doesn't trigger hashchange, but we don't need to update from the hash,
		// we're just syncing the URL with the new state.
		history.replaceState(null, null, newHash);
	}
};

const undoable = (fn) => {
	if (!editing) {
		return; // @TODO: allow undoing during gameplay again, but using rewind system
	}
	editorLevelState = serializeToJSON(currentLevel);
	undos.push(editorLevelState);
	redos.length = 0;
	if (fn) {
		fn();
		save();
	}
};
const undoOrRedo = (undos, redos) => {
	const originalTitle = currentLevel.title;
	if (undos.length === 0) {
		return false;
	}
	redos.push(serializeToJSON(currentLevel));
	editorLevelState = undos.pop();
	deserializeJSON(editorLevelState);
	currentLevel.title = originalTitle; // this is to avoid creating many autosave slots - don't allow undoing title change
	updateEditorUIForLevelChange(currentLevel); // for title update
	save();
	return true;
};
let recentUndoSound = 0;
let recentRedoSound = 0;
const undo = () => {
	if (!editing) {
		toggleEditing();
		return;
	}
	const didSomething = undoOrRedo(undos, redos);
	if (didSomething) {
		playSound("undo", 1 / (1 + recentUndoSound / 2), Math.min(0.2, recentUndoSound / 5));
		recentUndoSound += 1;
		setTimeout(() => {
			recentUndoSound -= 1;
		}, 400);
	}
	// @TODO: undo view too
};
const redo = () => {
	if (!editing) {
		toggleEditing();
		return;
	}
	const didSomething = undoOrRedo(redos, undos);
	if (didSomething) {
		playSound("redo", (1 + recentRedoSound / 10));
		recentRedoSound += 1;
		setTimeout(() => {
			recentRedoSound -= 1;
		}, 400);
	}
};

const saveToFile = () => {
	// this is sort of a weird way for this to work!
	undoable(() => {
		deserializeJSON(editorLevelState);
	});
	const file = new Blob([serializeLevel(currentLevel)], { type: "text/plain" });
	const a = document.createElement("a");
	const url = URL.createObjectURL(file);
	a.href = url;
	a.download = `${currentLevel.title}.txt`;
	document.body.appendChild(a);
	a.click();
	setTimeout(() => {
		document.body.removeChild(a);
		window.URL.revokeObjectURL(url);
	}, 0);
};

const openFromFile = (file) => {
	if (!editing) {
		toggleEditing();
	}
	const reader = new FileReader();
	reader.onload = (readerEvent) => {
		const content = readerEvent.target.result;
		try {
			if (content.match(/^\s*{/)) {
				deserializeJSON(content);
				initLevel(currentLevel);
			} else {
				initLevel(loadLevelFromText(content));
			}
			currentLevel.title = currentLevel.title || file.name.replace(/\.(json|txt)$/, "");
			save();
		} catch (error) {
			showErrorMessage("Failed to load from file.", error);
		}
	};
	reader.onerror = () => {
		showErrorMessage("Failed to read file.", reader.error);
	};
	reader.readAsText(file, "UTF-8");
};

const openFromFileDialog = () => {
	const input = document.createElement("input");
	input.type = "file";
	input.onchange = (event) => {
		const file = event.target.files[0];
		openFromFile(file);
	};
	input.click();
};

const selectAll = () => {
	if (!editing) {
		toggleEditing();
	}
	for (const entity of entities) {
		entity.selected = true;
	}
};
const flipSelected = () => {
	if (!editing) {
		return;
	}
	let maxX = -Infinity;
	let minX = Infinity;
	const selectedEntities = entities.filter((entity) => entity.selected);
	for (const entity of selectedEntities) {
		maxX = Math.max(maxX, entity.x + entity.width);
		minX = Math.min(minX, entity.x);
	}
	let flipCenterX = (maxX + minX) / 2;
	// Note the divide by two. This handles even and odd numbers of cells wide.
	// It doesn't however handle sub-grid widths. But I'm not sure what it should do in that case.
	flipCenterX = round(flipCenterX, 15 / 2);

	undoable(() => {
		for (const entity of selectedEntities) {
			entity.x = flipCenterX - (entity.x - flipCenterX + entity.width);
			if ("facing" in entity) {
				entity.facing = -entity.facing;
			}
		}
	});
	playSound("turn");
};
const toggleSelected = () => {
	if (!editing) {
		return;
	}
	if (entities.some((entity) => entity.selected && "on" in entity)) {
		let toggledSomethingOn = false;
		let toggledSomethingOff = false;
		let toggledASwitch = false;
		undoable(() => {
			for (const entity of entities) {
				if (entity.selected && "on" in entity) {
					entity.on = !entity.on;
					if (entity.type === "switch") {
						toggledASwitch = true;
					} else if (entity.on) {
						toggledSomethingOn = true;
					} else {
						toggledSomethingOff = true;
					}
				}
			}
		});
		if (toggledSomethingOn) {
			playSound("switchOn");
		}
		if (toggledSomethingOff) {
			playSound("switchOff");
		}
		if (toggledASwitch) {
			playSound("switchClick");
		}
	}
};
const deleteSelected = () => {
	if (!editing) {
		return;
	}
	if (entities.some((entity) => entity.selected)) {
		undoable(() => {
			entities = entities.filter((entity) => !entity.selected);
			currentLevel.entities = entities;
		});
		playSound("delete");
	}
};
const copySelected = () => {
	if (!editing) {
		return;
	}
	if (entities.some((entity) => entity.selected)) {
		clipboard.entitiesJSON = JSON.stringify(entities.filter((entity) => entity.selected));
		if (navigator.clipboard && navigator.clipboard.writeText) {
			navigator.clipboard.writeText(clipboard.entitiesJSON);
		}
		playSound("copyPaste");
	}
};
const cutSelected = () => {
	copySelected();
	deleteSelected();
};
const pasteEntities = (newEntities) => {
	undoable();
	for (const entity of entities) {
		delete entity.selected;
		delete entity.grabbed;
		delete entity.grabOffset;
	}
	dragging = [];

	for (const entity of newEntities) {
		entity.selected = true;
		entity.grabbed = true;
		entity.id = getID();
		entities.push(entity);
		dragging.push(entity);
	}

	const centers = newEntities.map((entity) => ({
		x: entity.x + entity.width / 2,
		y: entity.y + entity.height / 2,
	}));
	const collectiveCenter = { x: 0, y: 0 };
	for (const entityCenter of centers) {
		collectiveCenter.x += entityCenter.x;
		collectiveCenter.y += entityCenter.y;
	}
	collectiveCenter.x /= centers.length;
	collectiveCenter.y /= centers.length;

	const offsetX = -floor(collectiveCenter.x, 15);
	const offsetY = -floor(collectiveCenter.y, 18);

	for (const entity of newEntities) {
		entity.grabOffset = {
			x: entity.x + offsetX,
			y: entity.y + offsetY,
		};
	}
};
const pasteFromClipboard = async () => {
	if (!editing) {
		return;
	}
	let { entitiesJSON } = clipboard;
	if (navigator.clipboard && navigator.clipboard.readText) {
		const text = await navigator.clipboard.readText();
		if (text && text.trim()[0] === "{") {
			entitiesJSON = text;
		}
	}
	const newEntities = JSON.parse(entitiesJSON);
	pasteEntities(newEntities);
	playSound("copyPaste");
};

// #endregion
//                                              ____
// █████ █████ █   █ █████ █████ █████     _[]_/____\__n_
// █     █   █ ██ ██ █     █   █ █   █    |_____.--.__()_|
// █     █████ █ █ █ █████ █████ █████    |LI  //# \\    |
// █     █   █ █   █ █     █  █  █   █    |    \\__//    |
// █████ █   █ █   █ █████ █  ██ █   █    |     '--'     |
//                                        '--------------'
// #region Camera

const worldToCanvas = (worldX, worldY) => ({
	x: (worldX - viewport.centerX) * viewport.scale + Math.floor(canvas.width / 2),
	y: (worldY - viewport.centerY) * viewport.scale + Math.floor(canvas.height / 2),
});
const canvasToWorld = (canvasX, canvasY) => ({
	x: (canvasX - Math.floor(canvas.width / 2)) / viewport.scale + viewport.centerX,
	y: (canvasY - Math.floor(canvas.height / 2)) / viewport.scale + viewport.centerY,
});

const zoomTo = (newScale, focalPointOnCanvas = { x: canvas.width / 2, y: canvas.height / 2 }) => {
	if (pointerEventCache.length === 2) {
		const [a, b] = pointerEventCache;
		focalPointOnCanvas.x = (a.pageX + b.pageX) / 2 * window.devicePixelRatio;
		focalPointOnCanvas.y = (a.pageY + b.pageY) / 2 * window.devicePixelRatio;
	}
	// const oldScale = viewport.scale;
	const focalPointInWorld = canvasToWorld(focalPointOnCanvas.x, focalPointOnCanvas.y);
	viewport.scale = newScale;
	const mouseInWorldAfterZoomButBeforePan = canvasToWorld(focalPointOnCanvas.x, focalPointOnCanvas.y);
	// viewport.scale = oldScale;

	viewport.centerX += focalPointInWorld.x - mouseInWorldAfterZoomButBeforePan.x;
	viewport.centerY += focalPointInWorld.y - mouseInWorldAfterZoomButBeforePan.y;
	viewport.scale = newScale;
};
const scales = [1 / 15, 1 / 10, 1 / 5, 1 / 3, 1 / 2, 3 / 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const getScaleIndex = () => {
	for (let index = 0; index < scales.length; index++) {
		if (scales[index] >= viewport.scale) {
			return index;
		}
	}
	return scales.length - 1;
};
const zoomIn = (focalPointOnCanvas) => {
	zoomTo(scales[Math.min(getScaleIndex() + 1, scales.length - 1)], focalPointOnCanvas);
};
const zoomOut = (focalPointOnCanvas) => {
	zoomTo(scales[Math.max(getScaleIndex() - 1, 0)], focalPointOnCanvas);
};

// #endregion
//                                                 . -------------------------------------------------------------------.
//                                                 | [Esc] [F1][F2][F3][F4][F5][F6][F7][F8][F9][F0][F10][F11][F12] o o o|
// █   █ █████ █   █ ████  █████ █████ █████ ████  | [`][1][2][3][4][5][6][7][8][9][0][-][=][_<_] [I][H][U] [N][/][*][-]|
// █  █  █     █   █ █   █ █   █ █   █ █   █ █   █ | [|-][Q][W][E][R][T][Y][U][I][O][P][{][}] | | [D][E][D] [7][8][9]|+||
// ███   █████  █ █  █████ █   █ █████ █████ █   █ | [CAP][A][S][D][F][G][H][J][K][L][;]['][#]|_|           [4][5][6]|_||
// █  █  █       █   █   █ █   █ █   █ █  █  █   █ | [^][\][Z][X][C][V][B][N][M][,][.][/] [__^__]    [^]    [1][2][3]| ||
// █   █ █████   █   ████  █████ █   █ █  ██ ████  | [c]   [a][________________________][a]   [c] [<][V][>] [ 0  ][.]|_||
//                                                 `--------------------------------------------------------------------'
// #region Keyboard

addEventListener("keydown", (event) => {
	if (event.defaultPrevented) {
		return; // Do nothing if the event was already processed
	}
	if (event.target.tagName.match(/input|textarea|select/i)) {
		return;
	}
	if (event.target.tagName.match(/button/i) && (event.key === " " || event.key === "Enter")) {
		return;
	}
	keys[event.code] = true;
	if (event.key.match(/^Arrow/)) {
		keys[event.key] = true;
	}
	if (event.code === "Equal" || event.code === "NumpadAdd") {
		zoomIn();
	}
	if (event.code === "Minus" || event.code === "NumpadSubtract") {
		zoomOut();
	}
	if (event.ctrlKey && event.key === "p") {
		event.preventDefault();
		// playback saved solution recording
		const json = localStorage[storageKeys.solutionRecording(currentLevel.title)];
		if (json) {
			toggleEditing();
			if (editing) {
				toggleEditing();
			}
			playbackEvents = JSON.parse(json);
		}
	}
	if (event.altKey && (event.code === "Enter" || event.code === "NumpadEnter")) {
		event.preventDefault();
		toggleFullscreen();
	}
	switch (event.key.toUpperCase()) {
		case " ": // Spacebar
		case "P":
			if (!event.repeat) {
				// togglePause();
			}
			break;
		case "E":
			if (!event.repeat) {
				toggleEditing();
			}
			break;
		case "M":
			if (!event.repeat) {
				toggleMute();
			}
			break;
		case "`":
			if (!event.repeat) {
				toggleShowDebug();
			}
			break;
		// case ",":
		// 	testVideo.currentTime -= 0.02;
		// 	localStorage[storageKeys.comparisonVideoTime] = testVideo.currentTime;
		// 	break;
		// case ".":
		// 	testVideo.currentTime += 0.02;
		// 	localStorage[storageKeys.comparisonVideoTime] = testVideo.currentTime;
		// 	break;
		// case ";":
		// 	aJunkbot.animationFrame -= 1;
		// 	if (aJunkbot.animationFrame < 0) {
		// 		aJunkbot.animationFrame = 0;
		// 	}
		// 	break;
		// case "'":
		// 	aJunkbot.animationFrame += 1;
		// 	break;
		case "F":
			if (!event.repeat) {
				flipSelected();
			}
			break;
		case "T":
			if (!event.repeat) {
				toggleSelected();
			}
			break;
		case "DELETE":
			if (!event.repeat) {
				deleteSelected();
			}
			break;
		case "Z":
			if (event.ctrlKey) {
				if (event.shiftKey) {
					redo();
				} else {
					undo();
				}
			} else {
				return;
			}
			break;
		case "Y":
			if (event.ctrlKey) {
				redo();
			} else {
				return;
			}
			break;
		case "X":
			if (event.ctrlKey && window.getSelection().toString() === "") {
				cutSelected();
			} else {
				return;
			}
			break;
		case "C":
			if (event.ctrlKey && !event.repeat && window.getSelection().toString() === "") {
				copySelected();
			} else {
				return;
			}
			break;
		case "V":
			if (event.ctrlKey) {
				pasteFromClipboard();
			} else {
				return;
			}
			break;
		case "A":
			if (event.ctrlKey) {
				selectAll();
			} else {
				return;
			}
			break;
		case "S":
			if (event.ctrlKey) {
				saveToFile();
			} else {
				return;
			}
			break;
		case "O":
			if (event.ctrlKey) {
				openFromFileDialog();
			} else {
				return;
			}
			break;
		default:
			// Don't prevent default action if event not handled
			return;
	}
	event.preventDefault();
});
addEventListener("keyup", (event) => {
	delete keys[event.code];
	if (event.key.match(/^Arrow/)) {
		delete keys[event.key];
	}
});

// #endregion
//                                    ____((______     ◣
// █   █ █████ █   █ █████ █████     |    _\\     |    ◼◼◣
// ██ ██ █   █ █   █ █     █         |   |_|_|    |    ◼◼◼◼◣
// █ █ █ █   █ █   █ █████ █████     |   |   |    |    ◼◼◼◼◼◼◣
// █   █ █   █ █   █     █ █         |   |___|    |    ◼◼◼◼◼◼◼◼◣
// █   █ █████ █████ █████ █████     |____________|    ◼◼◤◥◼◣
//                                                     ◤    ◥◼◣
// #region Mouse

addEventListener("drop", (event) => {
	event.preventDefault();
	if (event.dataTransfer.files.length > 0) {
		openFromFile(event.dataTransfer.files[0]);
	}
});
addEventListener("dragover", (event) => {
	event.preventDefault();
});

addEventListener("blur", () => {
	// prevent stuck keys
	keys = {};
	// prevent margin panning until pointermove
	mouse.x = undefined;
	mouse.y = undefined;
	mouse.worldX = undefined;
	mouse.worldY = undefined;
});
const releasePointer = (event) => {
	pointerEventCache = pointerEventCache.filter((oldEvent) => oldEvent.pointerId !== event.pointerId);

	if (pointerEventCache.length < 2) {
		prevPointerDist = -1;
	}
};
canvas.addEventListener("pointerout", (event) => {
	// prevent margin panning until pointermove
	mouse.x = undefined;
	mouse.y = undefined;
	// mouse.worldX = undefined;
	// mouse.worldY = undefined;
	releasePointer(event);
});
canvas.addEventListener("pointerup", (event) => {
	releasePointer(event);
});

const updateMouseWorldPosition = () => {
	if (mouse.x !== undefined && mouse.y !== undefined) {
		const worldPos = canvasToWorld(mouse.x, mouse.y);
		mouse.worldX = worldPos.x;
		mouse.worldY = worldPos.y;
	}
	if (selectionBox) {
		selectionBox.x2 = mouse.worldX;
		selectionBox.y2 = mouse.worldY;
	}
};
const updateMouse = (event) => {
	mouse.x = event.pageX * window.devicePixelRatio;
	mouse.y = event.pageY * window.devicePixelRatio;
	updateMouseWorldPosition();
	playthroughEvents.push({
		type: "pointer",
		x: mouse.worldX,
		y: mouse.worldY,
		t: frameCounter,
	});
};
const brickAt = ({ worldX, worldY }, { includeFixed = false } = {}) => {
	for (let i = entities.length - 1; i >= 0; i -= 1) {
		const entity = entities[i];
		if (
			(includeFixed || !entity.fixed) &&
			entity.x < worldX &&
			entity.x + entity.width > worldX &&
			entity.y < worldY &&
			entity.y + entity.height > worldY
		) {
			return entity;
		}
	}
};

const connects = (a, b, direction = 0) => (
	(
		(direction >= 0 && b.y === a.y + a.height) ||
		(direction <= 0 && b.y + b.height === a.y)
	) &&
	a.x + a.width > b.x &&
	a.x < b.x + b.width
);

const allConnectedToFixed = ({ ignoreEntities = [] } = {}) => {
	const connectedToFixed = [];
	const addAnyAttached = (entity) => {
		const entitiesToCheck = [].concat(
			entitiesByTopY[entity.y + entity.height] || [],
			entitiesByBottomY[entity.y] || [],
		);
		for (const otherEntity of entitiesToCheck) {
			if (
				entity.x + entity.width > otherEntity.x &&
				entity.x < otherEntity.x + otherEntity.width &&
				ignoreEntities.indexOf(otherEntity) === -1 &&
				connectedToFixed.indexOf(otherEntity) === -1
			) {
				connectedToFixed.push(otherEntity);
				addAnyAttached(otherEntity);
			}
		}
	};
	for (const entity of entities) {
		if (
			ignoreEntities.indexOf(entity) === -1 &&
			connectedToFixed.indexOf(entity) === -1
		) {
			if (entity.fixed) {
				connectedToFixed.push(entity);
				addAnyAttached(entity);
			}
		}
	}
	return connectedToFixed;
};

const connectsToFixed = (startEntity, { direction = 0, ignoreEntities = [] } = {}) => {
	const visited = [];
	const search = (fromEntity) => {
		if (currentLevel.bounds) {
			if (fromEntity.y + fromEntity.height >= currentLevel.bounds.y + currentLevel.bounds.height) {
				// for case of non-fixed brick at bottom of level
				// which shouldn't happen in the game, but can happen in the editor
				return true;
			}
		}
		const entitiesToCheck = [].concat(
			(fromEntity !== startEntity || direction !== -1) && entitiesByTopY[fromEntity.y + fromEntity.height] || [],
			(fromEntity !== startEntity || direction !== +1) && entitiesByBottomY[fromEntity.y] || [],
		);
		for (const otherEntity of entitiesToCheck) {
			if (
				!otherEntity.grabbed &&
				ignoreEntities.indexOf(otherEntity) === -1 &&
				visited.indexOf(otherEntity) === -1 &&
				fromEntity.x + fromEntity.width > otherEntity.x &&
				fromEntity.x < otherEntity.x + otherEntity.width
			) {
				visited.push(otherEntity);
				if (otherEntity.fixed) {
					return true;
				}
				if (search(otherEntity)) {
					return true;
				}
			}
		}
		return false;
	};
	return search(startEntity);
};

const possibleGrabs = ({ worldX, worldY } = mouse) => {
	const findAttached = (brick, direction, attached, topLevel) => {
		const entitiesToCheck1 = [].concat(
			entitiesByTopY[brick.y + brick.height] || [],
			entitiesByBottomY[brick.y] || [],
		);
		for (const entity of entitiesToCheck1) {
			if (
				entity !== brick &&
				// for things that aren't bricks, check above, in case someone's standing on these blocks
				connects(brick, entity, entity.type === "brick" ? direction : -1) &&
				// prevent heavy recursion when e.g. there's a pyramid of blocks
				attached.indexOf(entity) === -1
			) {
				if (entity.fixed || entity.type !== "brick") {
					// can't drag in this direction (e.g. the block might be sandwiched) or
					// junkbot or an enemy might be standing on these blocks
					return false;
				} else {
					attached.push(entity);
					const okay = findAttached(entity, direction, attached);
					if (!okay) {
						return false;
					}
				}
			}
		}
		if (topLevel) {
			for (const brick of attached) {
				const entitiesToCheck2 = [].concat(
					entitiesByTopY[brick.y + brick.height] || [],
					entitiesByBottomY[brick.y] || [],
				);
				for (const entity of entitiesToCheck2) {
					if (
						!entity.fixed &&
						(entity.type === "brick" || entity.type === "jump" || entity.type === "shield") &&
						brick.x + brick.width > entity.x &&
						brick.x < entity.x + entity.width &&
						attached.indexOf(entity) === -1 &&
						!connectsToFixed(entity, { ignoreEntities: attached })
					) {
						const entitiesToCheck3 = entitiesByBottomY[entity.y] || [];
						for (const junk of entitiesToCheck3) {
							if (junk.type !== "brick") {
								if (
									entity.x + entity.width > junk.x &&
									entity.x < junk.x + junk.width
								) {
									return false;
								}
							}
						}
						attached.push(entity);
					}
				}
			}
		}
		return true;
	};
	if (paused && !editing) {
		return [];
	}
	const brick = brickAt({ worldX, worldY }, { includeFixed: editing });
	if (!brick) {
		return [];
	}
	const grabs = [];
	if (editing && (keys.ControlLeft || keys.ControlRight)) {
		grabs.push([brick]);
		return grabs;
	}
	if (editing && brick.selected) {
		grabs.selection = entities.filter((entity) => entity.selected);
		grabs.push(grabs.selection);
		return grabs;
	}
	if (brick.fixed || (brick.type !== "brick" && brick.type !== "jump" && brick.type !== "shield")) {
		if (editing) {
			grabs.push([brick]);
			return grabs;
		}
		return [];
	}

	const grabDownward = [brick];
	const grabUpward = [brick];
	const canGrabDownward = findAttached(brick, +1, grabDownward, true);
	const canGrabUpward = findAttached(brick, -1, grabUpward, true);
	if (editing && canGrabDownward === canGrabUpward) {
		grabs.push([brick]);
		return grabs;
	}
	if (canGrabDownward) {
		grabs.push(grabDownward);
		grabs.downward = grabDownward;
	}
	if (canGrabUpward) {
		grabs.push(grabUpward);
		grabs.upward = grabUpward;
	}
	return grabs;
};

let pendingGrabs = [];
const startGrab = (grab, {
	grabType,
	duringPlayback = false,
	mouse: mouseParam = mouse // using destructuring to default to global mouse for option named mouse
}) => {
	if (!grab) {
		if (duringPlayback) {
			// showErrorMessage("Grab is not possible. Something must be different from the recording during playback, or some other bug has occurred.");
			desynchronized = true;
		}
		return;
	}
	playthroughEvents.push({
		type: "pickup",
		x: mouseParam.worldX,
		y: mouseParam.worldY,
		// time: performance.now(), // playback would not be reproducible if based on real time
		t: frameCounter,
		grabType,
		editing,
	});

	undoable();
	dragging = [...grab];
	for (const brick of dragging) {
		brick.grabbed = true;
		brick.grabOffset = {
			// x: brick.x - floor(mouseParam.worldX, snapX),
			// y: brick.y - floor(mouseParam.worldY, snapY),
			// so you can place blocks that were grabbed when they weren't on the grid:
			// (note: this does lose relative sub-grid positions of bricks)
			x: floor(brick.x, snapX) - floor(mouseParam.worldX, snapX),
			y: floor(brick.y, snapY) - floor(mouseParam.worldY, snapY),
		};
		if (editing) {
			brick.selected = true;
		}
	}
	playSound("blockPickUp");
	if (!editing) {
		moves += 1;
	}
};

canvas.addEventListener("wheel", (event) => {
	updateMouse(event);
	event.preventDefault();
	// Normalize to deltaX in case shift modifier is used on Mac
	const delta = event.deltaY === 0 && event.deltaX ? event.deltaX : event.deltaY;
	if (delta < 0) {
		zoomIn({ x: mouse.x, y: mouse.y });
	} else {
		zoomOut({ x: mouse.x, y: mouse.y });
	}
});

canvas.addEventListener("pointermove", (event) => {
	updateMouse(event);
	if (pendingGrabs.length) {
		const threshold = 10;
		if (
			mouse.y < mouse.atDragStart.y - threshold
		) {
			startGrab(pendingGrabs.upward, { grabType: "upward" });
			pendingGrabs = [];
		}
		if (
			mouse.y > mouse.atDragStart.y + threshold
		) {
			startGrab(pendingGrabs.downward, { grabType: "downward" });
			pendingGrabs = [];
		}
	}
	// Find this pointer in the cache and update its record with the latest event
	for (let i = 0; i < pointerEventCache.length; i++) {
		if (event.pointerId === pointerEventCache[i].pointerId) {
			pointerEventCache[i] = event;
			break;
		}
	}
	// If two pointers are down, check for pinch gestures
	if (pointerEventCache.length === 2) {
		const [a, b] = pointerEventCache;
		const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);

		if (prevPointerDist > 0) {
			if (dist > prevPointerDist + 50) {
				zoomIn();
				prevPointerDist = dist;
			}
			if (dist < prevPointerDist - 50) {
				zoomOut();
				prevPointerDist = dist;
			}
		} else {
			prevPointerDist = dist;
		}
	}
});
canvas.addEventListener("pointerdown", (event) => {
	if (!muted) {
		audioCtx.resume();
	}
	canvas.focus(); // for keyboard shortcuts, after interacting with dropdown
	window.getSelection().removeAllRanges(); // for keyboard shortcuts for copy and paste after selecting text
	if (paused && !editing) {
		return;
	}
	updateMouse(event);
	if (event.button !== 0) {
		return; // right click is handled by contextmenu
	}
	pointerEventCache.push(event);
	mouse.atDragStart = {
		x: mouse.x,
		y: mouse.y,
		worldX: mouse.worldX,
		worldY: mouse.worldY,
	};
	if (dragging.length === 0) {
		sortEntitiesForRendering(entities);
		const grabs = possibleGrabs();
		if (!grabs.selection) {
			for (const entity of entities) {
				delete entity.selected;
			}
		}
		if (grabs.length === 1) {
			startGrab(grabs[0], { grabType: "single" });
			playSound("blockClick");
		} else if (grabs.length) {
			pendingGrabs = grabs;
			playSound("blockClick");
		} else if (editing) {
			selectionBox = { x1: mouse.worldX, y1: mouse.worldY, x2: mouse.worldX, y2: mouse.worldY };
			playSound("selectStart");
		}
	}
});
canvas.addEventListener("contextmenu", async (event) => {
	event.preventDefault();
	const hoveredBrick = brickAt(mouse, { includeFixed: true });
	if (hoveredBrick && "switchID" in hoveredBrick) {
		// @TODO: better UI
		const newID = await showPrompt("Edit switch group ID for this brick", hoveredBrick.switchID);
		if (newID) {
			undoable(() => {
				hoveredBrick.switchID = newID;
			});
		}
	}
	if (hoveredBrick && "teleportID" in hoveredBrick) {
		// @TODO: better UI
		const newID = await showPrompt("Edit teleport group ID for this brick", hoveredBrick.teleportID);
		if (newID) {
			undoable(() => {
				hoveredBrick.teleportID = newID;
			});
		}
	}
});

// i.e. space generally free; filter for tangible entities
const notDroplet = (entity) => (
	entity.type !== "droplet"
);
// i.e. space generally free for junkbot walking
const notBinOrDroplet = (entity) => (
	entity.type !== "bin" &&
	entity.type !== "droplet"
);
// i.e. ground to walk on
const notBinOrDropletOrEnemyBot = (entity) => (
	notBinOrDroplet(entity) &&
	entity.type !== "gearbot" &&
	entity.type !== "climbbot" &&
	entity.type !== "flybot" &&
	entity.type !== "eyebot"
);

const updateDrag = (mouse) => {
	if (isFinite(mouse.worldX) && isFinite(mouse.worldY)) {
		for (const brick of dragging) {
			brick.x = floor(mouse.worldX, snapX) + brick.grabOffset.x;
			brick.y = floor(mouse.worldY, snapY) + brick.grabOffset.y;
			entityMoved(brick);
		}
	}
};

const canRelease = () => {
	if (dragging.length === 0) {
		return false; // optimization mainly - don't do allConnectedToFixed()
	}
	if (editing) {
		return true;
	}
	if (paused && !editing) {
		return false;
	}

	const connectedToFixed = allConnectedToFixed();

	const someCollision = dragging.some((entity) => (
		entityCollisionTest(entity.x, entity.y, entity, notDroplet)
	));
	if (someCollision) {
		return false;
	}

	if (dragging.every((entity) => entity.fixed)) {
		return true;
	}
	let connectsToCeiling = false;
	let connectsToFloor = false;
	for (const entity of dragging) {
		for (const otherEntity of entities) {
			if (
				!otherEntity.grabbed
			) {
				if (
					(
						otherEntity.type === "fire" ||
						otherEntity.type === "fan"
					) &&
					connects(entity, otherEntity)
				) {
					return false;
				}
				if (
					otherEntity.type === "brick" &&
					connectedToFixed.indexOf(otherEntity) !== -1
				) {
					if (connects(entity, otherEntity, -1)) {
						connectsToCeiling = true;
					}
					if (connects(entity, otherEntity, +1)) {
						connectsToFloor = true;
					}
				}
			}
		}
	}
	return connectsToCeiling !== connectsToFloor;
};

const finishDrag = ({
	duringPlayback = false,
	mouse: mouseParam = mouse // using destructuring to default to global mouse for option named mouse
} = {}) => {
	if (!canRelease()) {
		if (duringPlayback) {
			// showErrorMessage("Cannot release held block. Something must be different from the recording during playback, or some other bug has occurred.");
			desynchronized = true;
		}
		return;
	}
	playthroughEvents.push({
		type: "place",
		x: mouseParam.worldX,
		y: mouseParam.worldY,
		t: frameCounter,
		editing,
	});

	for (const entity of dragging) {
		delete entity.grabbed;
		delete entity.grabOffset;
	}
	dragging = [];
	playSound("blockDrop");
	save();
};

addEventListener("pointerup", () => {
	if (dragging.length) {
		finishDrag();
	} else if (selectionBox) {
		const toSelect = entitiesWithinSelection(selectionBox);
		for (const entity of toSelect) {
			entity.selected = true;
		}
		selectionBox = null;
		if (toSelect.length) {
			playSound("selectEnd");
		}
	}
});

// #endregion
//
// █████ ███ █   █ █   █ █     █████ █████ ███ █████ █   █
// █      █  ██ ██ █   █ █     █   █   █    █  █   █ ██  █        ,m^^^^m,       _-_-_-_-__   _____|\   ,m^^^^m, _-_-_-_-__
// █████  █  █ █ █ █   █ █     █████   █    █  █   █ █ █ █        'w,,,,w'      /-_-_-_-_ /| |       \  'w,,,,w'/-_-_-_-_ /|
//     █  █  █   █ █   █ █     █   █   █    █  █   █ █  ██         ┇ ━▶ ┇      |_o__o__o_|/| |_____  /   ┇ ◀━ ┇|_o__o__o_|/|
// █████ ███ █   █ █████ █████ █   █   █   ███ █████ █   █        'w.,,.w'     |_o__o__O_|/        |/   'w.,,.w|_o__o__O_|/
//
// #region Simulation

// #@: simulateCrate, simulateBlock, simulateBrick, falling behavior
const simulateGravity = () => {
	for (const entity of entities) {
		if (
			!entity.fixed &&
			!entity.grabbed &&
			!entity.floating &&
			entity.type !== "droplet" &&
			entity.type !== "junkbot" &&
			entity.type !== "climbbot" &&
			entity.type !== "flybot" &&
			entity.type !== "eyebot"
		) {
			// if not settled
			if (
				!rectangleLevelBoundsCollisionTest(entity.x, entity.y + 1, entity.width, entity.height) &&
				!connectsToFixed(entity, { direction: (entity.type === "junkbot" || entity.type === "gearbot" || entity.type === "crate" || entity.type === "bin") ? 1 : 0 })
			) {
				// just for dinosaur test case level,
				// where there are some blocks meant to stick inside the ceiling
				if (entityCollisionTest(entity.x, entity.y, entity, notDroplet)) {
					debug("GRAVITY COLLISION", `${entity.type} stuck in ground at ${entity.x}, ${entity.y}`);
					return;
				}

				// first try a step of 18 (1 grid cell) downwards,
				// then reign it in if there's a collision
				const cellDownY = entity.y + 18;
				// find highest up collision (if any)
				const ground = entityCollisionAll(entity.x, cellDownY + 1, entity, notDroplet)
					.sort((a, b) => a.y - b.y)[0];
				debug("GRAVITY COLLISION", `ground: ${JSON.stringify(ground, null, "\t")}`);
				if (ground) {
					entity.y = ground.y - entity.height;
					entityMoved(entity);
				} else {
					entity.y = cellDownY;
					entityMoved(entity);
				}
			}
		}
	}
};

const hurtJunkbot = (junkbot, cause) => {
	if (junkbot.dying || junkbot.dead || junkbot.grabbed) {
		return;
	}
	// Play sound even if shielded,
	// but not if losing shield because then it would repeat and sound ugly.
	// This has to be before junkbot.losingShield is set, so it can play the first time.
	if (!junkbot.losingShield) {
		// @TODO: rename sound effects, as they're not just for death
		if (cause === "fire") {
			playSound("deathByFire");
		} else if (cause === "water") {
			playSound("deathByWater");
		} else if (cause === "laser") {
			playSound("deathByLaser");
		} else {
			playSound("deathByBot");
		}
	}
	if (junkbot.armored) {
		if (!junkbot.losingShield) {
			junkbot.losingShield = true;
			// don't reset junkbot.losingShieldTime to 0
			// it wouldn't make sense for multiple hits to extend the shield
			// (it should be reset elsewhere)
		}
	} else {
		junkbot.animationFrame = 0;
		junkbot.collectingBin = false;
		junkbot.dying = true;
		if (cause === "water") {
			junkbot.dyingFromWater = true;
		}
	}
};

const walk = (junkbot) => {
	const posInFront = { x: junkbot.x + junkbot.facing * 15, y: junkbot.y };
	const stepOrWall = entityCollisionTest(posInFront.x, posInFront.y, junkbot, notBinOrDropletOrEnemyBot);
	if (stepOrWall) {
		// can we step up?
		const posStepUp = { x: posInFront.x, y: stepOrWall.y - junkbot.height };
		if (
			posStepUp.y - junkbot.y >= -18 &&
			posStepUp.y - junkbot.y < 0 &&
			!entityCollisionTest(posStepUp.x, posStepUp.y, junkbot, notBinOrDroplet)
		) {
			debug("JUNKBOT", "STEP UP");
			junkbot.x = posStepUp.x;
			junkbot.y = posStepUp.y;
			entityMoved(junkbot);
			return;
		}
	}
	// is there solid ground ahead to walk on?
	const ground = entityCollisionTest(posInFront.x, posInFront.y + 1, junkbot, notBinOrDropletOrEnemyBot);
	if (
		ground &&
		!entityCollisionTest(posInFront.x, posInFront.y, junkbot, notBinOrDroplet)
	) {
		debug("JUNKBOT", "WALK");
		junkbot.x = posInFront.x;
		junkbot.y = posInFront.y;
		entityMoved(junkbot);
		return;
	}
	let step = entityCollisionAll(posInFront.x, posInFront.y + 18 + 1, junkbot, notBinOrDropletOrEnemyBot)
		.sort((a, b) => a.y - b.y)[0];
	if (step) {
		// can we step down?
		// debug("JUNKBOT", `step: ${JSON.stringify(step, null, "\t")}`);
		const posStepDown = { x: posInFront.x, y: step.y - junkbot.height };
		step = entityCollisionAll(posStepDown.x, posStepDown.y + 1, junkbot, notBinOrDropletOrEnemyBot)
			.sort((a, b) => a.y - b.y)[0];
		if (
			posStepDown.y - junkbot.y <= 18 &&
			posStepDown.y - junkbot.y > 0 &&
			step &&
			!entityCollisionTest(posStepDown.x, posStepDown.y, junkbot, notBinOrDroplet)
		) {
			debug("JUNKBOT", "STEP DOWN");
			junkbot.x = posStepDown.x;
			junkbot.y = posStepDown.y;
			entityMoved(junkbot);
			return;
		}
	}
	debug("JUNKBOT", "CLIFF/WALL/BOT - TURN AROUND");
	junkbot.facing *= -1;
	playSound("turn");
	return "turned";
};

const findLinkedTeleport = (teleport) => (
	entities.find((entity) => (
		entity.type === "teleport" &&
		entity.teleportID === teleport.teleportID &&
		entity !== teleport
	))
);

const simulateJunkbot = (junkbot) => {
	const aboveHead = entityCollisionTest(junkbot.x, junkbot.y - 1, junkbot, notDroplet);
	const headLoaded = aboveHead && (
		junkbot.floating || (
			!aboveHead.fixed &&
			!connectsToFixed(aboveHead, { ignoreEntities: [junkbot] }) &&
			aboveHead.type !== "levelBounds" &&
			aboveHead.type !== "flybot" &&
			aboveHead.type !== "eyebot"
		)
	);
	if (junkbot.headLoaded && !headLoaded) {
		junkbot.headLoaded = false;
	} else if (headLoaded && !junkbot.headLoaded && !junkbot.grabbed) {
		junkbot.headLoaded = true;
		playSound("headBonk");
	}
	if (junkbot.losingShield) {
		junkbot.losingShieldTime += 1;
		if (junkbot.losingShieldTime > 36) { // already compared to reference video
			junkbot.armored = false;
			junkbot.losingShield = false;
			junkbot.losingShieldTime = 0; // important for next damage event
			playSound("losePowerup");
		}
	}
	junkbot.animationFrame += 1;
	if (junkbot.collectingBin) {
		if (junkbot.animationFrame >= 17) {
			junkbot.collectingBin = false;
			junkbot.animationFrame = 0;
		} else {
			return;
		}
	}
	if (junkbot.dying) {
		if (junkbot.animationFrame >= 10) {
			junkbot.animationFrame = 0;
			junkbot.dead = true;
		}
		return;
	}
	if (junkbot.gettingShield) {
		if (junkbot.animationFrame >= 11) {
			junkbot.gettingShield = false;
			junkbot.armored = true;
		} else {
			return;
		}
	}
	const inside = entityCollisionTest(junkbot.x, junkbot.y, junkbot, notDroplet);
	if (inside) {
		debug("JUNKBOT", "STUCK IN WALL");
		return;
	}
	if (junkbot.floating) {
		const abovePos = { x: junkbot.x, y: junkbot.y - 18 };
		const aboveHead = entityCollisionTest(abovePos.x, abovePos.y, junkbot, notDroplet);
		if (aboveHead) {
			debug("JUNKBOT", "FLOATING - CAN'T GO UP");
		} else {
			debug("JUNKBOT", "FLOATING - GO UP");
			junkbot.x = abovePos.x;
			junkbot.y = abovePos.y;
			entityMoved(junkbot);
		}
		return;
	}
	if (junkbot.momentumX === undefined) {
		junkbot.momentumX = 0;
	}
	if (junkbot.momentumY === undefined) {
		junkbot.momentumY = 0;
	}
	junkbot.momentumX = Math.min(5, Math.max(-5, junkbot.momentumX));
	junkbot.momentumY = Math.min(5, Math.max(-5, junkbot.momentumY));
	const inAir = !entityCollisionTest(junkbot.x, junkbot.y + 1, junkbot, notDroplet);
	const unaligned = junkbot.x % 15 !== 0;
	const jumpStarting = junkbot.momentumY < -2;
	if (inAir || jumpStarting || unaligned) {
		if (inAir) {
			debug("JUNKBOT", "IN AIR - DO GRID-BASED BALLISTIC MOTION");
		} else if (jumpStarting) {
			debug("JUNKBOT", "JUMP - DO GRID-BASED BALLISTIC MOTION");
		} else if (unaligned) {
			debug("JUNKBOT", "UNALIGNED - DO (BALLISTIC MOTION AND) SNAPPING TO GROUND");
			// @TODO: handle this case again, snap junkbot to grid
		}

		// To debug momentum, uncomment drawText in drawJunkbot.
		const dirX = junkbot.momentumY < -2 ? 0 : Math.sign(junkbot.momentumX);
		const dirY = Math.sign(junkbot.momentumY);
		const newX = junkbot.x + dirX * 15;
		const newY = junkbot.y + dirY * 18;
		if (entityCollisionTest(newX, newY, junkbot, notDroplet)) {
			if (!entityCollisionTest(junkbot.x, newY, junkbot, notDroplet)) {
				// moving Y only is not a collision
				junkbot.momentumX = 0;
				junkbot.y = newY;
			} else if (!entityCollisionTest(newX, junkbot.y, junkbot, notDroplet)) {
				// moving X only is not a collision
				junkbot.momentumY = 0;
				junkbot.x = newX;
			} else {
				debug("JUNKBOT", "collision in both X and Y directions");
				junkbot.momentumX = 0;
				junkbot.momentumY = 0;
			}
			playSound("headBonk");
		} else {
			junkbot.x = newX;
			junkbot.y = newY;
			junkbot.momentumX -= dirX; // -= Math.sign(junkbot.momentumX) would be different
		}
		junkbot.momentumY += 1;
		if (junkbot.momentumY < 5) {
			junkbot.animationFrame = 9; // stick leg closer to the camera out backwards
		}
		if (junkbot.momentumY === 5) {
			playSound("fall");
		}

		const jumpBrick = entityCollisionTest(junkbot.x, junkbot.y + 1, junkbot, (brick) => brick.type === "jump");
		const ahead = entityCollisionTest(junkbot.x + junkbot.facing * 15, junkbot.y, junkbot, notDroplet);
		if (
			jumpBrick &&
			jumpBrick.x <= junkbot.x &&
			jumpBrick.x + jumpBrick.width >= junkbot.x + junkbot.width &&
			!ahead // prevent getting stuck bouncing up against a wall
		) {
			// @TODO: DRY with other jump code
			// Might also want to trigger related behavior like dying on fire bricks here
			// Note this must be after junkbot.momentumY += 1;
			if (!jumpBrick.active) {
				junkbot.animationFrame = 0;
				junkbot.momentumY = -3;
				junkbot.momentumX = junkbot.facing * 5;
				playSound("jump");
				jumpBrick.active = true;
				jumpBrick.animationFrame = 0;
			}
		}
		entityMoved(junkbot);
		return;
	}
	if (junkbot.animationFrame % 5 === 4) {
		const posInFront = { x: junkbot.x + junkbot.facing * 15, y: junkbot.y };
		const cratesInFront = rectangleCollisionAll(posInFront.x, posInFront.y, junkbot.width, junkbot.height + 1, (otherEntity) => (
			otherEntity.type === "crate" && (
				otherEntity.x + otherEntity.width <= junkbot.x ||
				junkbot.x + junkbot.width <= otherEntity.x
			)
		));
		if (cratesInFront.every((crate) => !entityCollisionTest(crate.x + junkbot.facing * 15, crate.y, crate, notDroplet))) {
			for (const crate of cratesInFront) {
				crate.x += junkbot.facing * 15;
			}
		}
		const turnedAround = walk(junkbot) === "turned";
		const groundLevelEntities = entitiesByTopY[junkbot.y + junkbot.height] || [];
		for (const groundLevelEntity of groundLevelEntities) {
			if (
				groundLevelEntity.x <= junkbot.x &&
				groundLevelEntity.x + groundLevelEntity.width >= junkbot.x + junkbot.width &&
				!turnedAround // @TODO: what about for fire and shield bricks? I confirmed this applies to jump bricks and switches
			) {
				if (groundLevelEntity.type === "switch") {
					groundLevelEntity.on = !groundLevelEntity.on;
					for (const entity of entities) {
						if (
							entity.type !== "switch" &&
							"on" in entity &&
							"switchID" in entity &&
							entity.switchID === groundLevelEntity.switchID
						) {
							entity.on = !entity.on;
						}
					}
					playSound("switchClick");
					playSound(groundLevelEntity.on ? "switchOn" : "switchOff");
				} else if (groundLevelEntity.type === "fire" && groundLevelEntity.on) {
					hurtJunkbot(junkbot, "fire");
				} else if (groundLevelEntity.type === "shield" && !groundLevelEntity.used && (junkbot.losingShield || !junkbot.armored)) {
					junkbot.animationFrame = 0;
					junkbot.gettingShield = true;
					junkbot.losingShield = false;
					junkbot.losingShieldTime = 0; // important for next damage event
					groundLevelEntity.used = true;
					playSound("getShield");
					playSound("getPowerup");
				} else if (groundLevelEntity.type === "jump") {
					// @TODO: DRY with copied jump code
					if (!groundLevelEntity.active) {
						junkbot.animationFrame = 0;
						junkbot.momentumY = -3;
						junkbot.momentumX = junkbot.facing * 5;
						playSound("jump");
						groundLevelEntity.active = true;
						groundLevelEntity.animationFrame = 0;
					}
				} else if (
					groundLevelEntity.type === "teleport" &&
					groundLevelEntity.timer === 0 &&
					junkbot.x === groundLevelEntity.x + 15
				) {
					const linkedTeleport = findLinkedTeleport(groundLevelEntity);
					if (linkedTeleport && !linkedTeleport.blocked) {
						junkbot.x = linkedTeleport.x + 15;
						junkbot.y = linkedTeleport.y - junkbot.height;
						linkedTeleport.timer = TELEPORT_COOLDOWN;
						groundLevelEntity.timer = TELEPORT_COOLDOWN;
						entityMoved(junkbot);
						playSound("teleport");
					}
				}
			}
		}
	}

	const bin = entityCollisionTest(junkbot.x + junkbot.facing * 15, junkbot.y, junkbot, (otherEntity) => (
		otherEntity.type === "bin"
	));
	if (bin) {
		junkbot.animationFrame = 0;
		junkbot.collectingBin = true;
		bin.removeBeforeRender = true; // it's important not to remove entities while iterating over them
		playSound("collectBin");
		playSound("collectBin2");
		collectBinTime = Date.now();
	}
};

const simulateGearbot = (gearbot) => {
	gearbot.animationFrame += 1;
	if (gearbot.animationFrame > 2) {
		gearbot.animationFrame = 0;
		const aheadPos = { x: gearbot.x + gearbot.facing * 15, y: gearbot.y };
		const ahead = entityCollisionTest(aheadPos.x, aheadPos.y, gearbot, notDroplet);
		const groundAhead = rectangleCollisionTest(gearbot.x + ((gearbot.facing === -1) ? -15 : gearbot.width), gearbot.y + 1, 15, gearbot.height, notDroplet);
		if (ahead) {
			if (ahead.type === "junkbot" && !ahead.dying && !ahead.dead) {
				hurtJunkbot(ahead, "bot");
			}
			gearbot.facing *= -1;
		} else if (groundAhead) {
			gearbot.x = aheadPos.x;
			gearbot.y = aheadPos.y;
			entityMoved(gearbot);
		} else {
			gearbot.facing *= -1;
		}
	}
};

// #@: simulateBin
const simulateScaredy = (bin) => {
	bin.animationFrame += 1;
	if (bin.animationFrame > 2) {
		bin.animationFrame = 0;
		const searchDist = 15 * 4; // AKA scare distance
		// @TODO: don't become scared through walls
		const searchRect = [bin.x - searchDist, bin.y, bin.width + searchDist * 2, bin.height];
		debugWorldSpaceRect(...searchRect);
		const junkbot = rectangleCollisionTest(...searchRect, (otherEntity) => otherEntity.type === "junkbot");
		if (junkbot) {
			bin.facing = junkbot.x > bin.x ? -1 : 1;
			const aheadPos = { x: bin.x + bin.facing * 15, y: bin.y };
			const ahead = entityCollisionTest(aheadPos.x, aheadPos.y, bin, notDroplet);
			if (ahead) {
				bin.facing = 0;
			} else {
				bin.x = aheadPos.x;
				bin.y = aheadPos.y;
				entityMoved(bin);
			}
		} else {
			bin.facing = 0;
		}
	}
};

const simulateFlybot = (flybot) => {
	// Could merge with eyebot movement:
	// doEyebotMovement(flybot);
	// return;

	flybot.animationFrame += 1;
	if (flybot.animationFrame % 2 === 0) {
		const aheadPos = { x: flybot.x + flybot.facing * 15, y: flybot.y };
		const ahead = entityCollisionTest(aheadPos.x, aheadPos.y, flybot, (otherEntity) => otherEntity.type !== "droplet");
		if (ahead) {
			if (ahead.type === "junkbot") {
				hurtJunkbot(ahead, "bot");
			}
			flybot.facing *= -1;
		} else {
			flybot.x = aheadPos.x;
			flybot.y = aheadPos.y;
			entityMoved(flybot);
		}
	}
};

// #@: simulateEyebot
const doEyebotTargeting = (eyebot) => {
	for (const [directionX, directionY] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
		const offsets = directionY !== 0 ? [[0, 0], [15, 0]] : [[0, 0], [0, 18]];
		for (const [offsetX, offsetY] of offsets) {
			const { hit } = raycast({
				startX: eyebot.x + offsetX,
				startY: eyebot.y + offsetY,
				width: 15,
				height: 18,
				directionX, directionY,
				maxSteps: 50,
				entityFilter: (entity) => entity.type !== "droplet" && entity !== eyebot,
			});
			if (hit && hit.type === "junkbot") {
				eyebot.facing = directionX;
				eyebot.facingY = directionY;
				eyebot.activeTimer = 110;
			}
		}
	}
};

const doEyebotMovement = (eyebot) => {
	eyebot.activeTimer -= 1;
	eyebot.animationFrame += 1;
	if (eyebot.animationFrame % ((eyebot.activeTimer > 0) ? 1 : 2) === 0) {
		const aheadPos = { x: eyebot.x + eyebot.facing * 15, y: eyebot.y + (eyebot.facingY || 0) * 18 };
		const ahead = entityCollisionTest(aheadPos.x, aheadPos.y, eyebot, (otherEntity) => otherEntity.type !== "droplet");
		if (ahead) {
			if (ahead.type === "junkbot") {
				hurtJunkbot(ahead, "bot");
			}
			eyebot.facing *= -1;
			eyebot.facingY *= -1;
		} else {
			eyebot.x = aheadPos.x;
			eyebot.y = aheadPos.y;
			entityMoved(eyebot);
		}
	}
};

const simulateClimbbot = (climbbot) => {
	climbbot.animationFrame += 1;
	if (climbbot.animationFrame > 6) {
		climbbot.animationFrame = 0;
		const asidePos = { x: climbbot.x + climbbot.facing * 15, y: climbbot.y };
		const groundAsidePos = { x: climbbot.x + climbbot.facing * 15, y: climbbot.y + 1 };
		const behindHorizontallyPos = { x: climbbot.x + climbbot.facing * -15, y: climbbot.y };
		const aheadPos = climbbot.facingY === 0 ? asidePos : { x: climbbot.x, y: climbbot.y + climbbot.facingY * 18 };
		const belowPos = { x: climbbot.x, y: climbbot.y + 18 };
		const aside = entityCollisionTest(asidePos.x, asidePos.y, climbbot, notDroplet);
		const groundAside = entityCollisionTest(groundAsidePos.x, groundAsidePos.y, climbbot, notBinOrDroplet);
		const ahead = entityCollisionTest(aheadPos.x, aheadPos.y, climbbot, notDroplet);
		const behindHorizontally = entityCollisionTest(behindHorizontallyPos.x, behindHorizontallyPos.y, climbbot, notDroplet);
		const below = entityCollisionTest(belowPos.x, belowPos.y, climbbot, notDroplet);

		if (ahead && ahead.type === "junkbot") {
			hurtJunkbot(ahead, "bot");
		}
		if (climbbot.facingY === -1) {
			if (!aside && groundAside) {
				climbbot.facingY = 0;
				climbbot.x = asidePos.x;
				climbbot.y = asidePos.y;
			} else if (climbbot.energy > 0 && !ahead) {
				climbbot.energy -= 1;
				climbbot.x = aheadPos.x;
				climbbot.y = aheadPos.y;
				entityMoved(climbbot);
			} else {
				climbbot.facingY = 1;
			}
		} else if (climbbot.facingY === 1) {
			if (below) {
				if (aside && behindHorizontally) {
					climbbot.facingY = -1;
					climbbot.energy = 3;
				} else {
					climbbot.facingY = 0;
					if (aside) {
						if (!behindHorizontally) {
							climbbot.facing *= -1;
							climbbot.x = behindHorizontallyPos.x;
							climbbot.y = behindHorizontallyPos.y;
							entityMoved(climbbot);
						}
					} else {
						climbbot.x = asidePos.x;
						climbbot.y = asidePos.y;
						entityMoved(climbbot);
					}
				}
			} else {
				climbbot.x = belowPos.x;
				climbbot.y = belowPos.y;
				entityMoved(climbbot);
			}
		} else {
			if (below) {
				if (aside) {
					climbbot.facingY = -1;
					climbbot.energy = 3;
				} else {
					climbbot.x = asidePos.x;
					climbbot.y = asidePos.y;
					entityMoved(climbbot);
				}
			} else {
				if (aside) {
					climbbot.facingY = 1;
				} else {
					// if (groundAside) {
					// 	climbbot.x = asidePos.x;
					// 	climbbot.y = asidePos.y;
					// 	entityMoved(climbbot);
					// } else {
					climbbot.facingY = 1;
					climbbot.x = belowPos.x;
					climbbot.y = belowPos.y;
					entityMoved(climbbot);
				}
			}
		}
	}
	// may not be necessary, but it "feels right" to reset this
	if (climbbot.facingY !== -1) {
		climbbot.energy = 0;
	}
};

const simulateDroplet = (droplet) => {
	if (droplet.splashing) {
		droplet.animationFrame += 1;
		if (droplet.animationFrame > 4) {
			droplet.removeBeforeRender = true; // it's important not to remove entities while iterating over them
		}
	} else {
		for (let i = 0; i < 18; i++) {
			const underneath = entitiesByTopY[droplet.y + droplet.height] || [];
			droplet.y += 1;
			entityMoved(droplet);
			for (const ground of underneath) {
				if (
					!ground.grabbed &&
					droplet.x + droplet.width > ground.x &&
					droplet.x < ground.x + ground.width &&
					// ground.type !== "pipe" && // actually it should hit pipes, ref: https://youtu.be/Z_PmQhrk5Zw?t=4418
					ground.type !== "droplet"
				) {
					if (ground.type === "junkbot") {
						hurtJunkbot(ground, "water");
					}

					droplet.splashing = true;
					droplet.animationFrame = 0;

					playSound(`drip${Math.floor(Math.random() * numDrips)}`);
					break;
				}
			}
		}
	}
};

const maxDripPeriod = 50;
const minDripPeriod = 20;
const simulatePipe = (pipe) => {
	pipe.timer -= 1;
	// @TODO: how do pipe drips work in the original game?
	// - after X time, C% chance every frame? (maybe with a max of Y time?)
	// - timer set to random value between X and Y?
	// - only initial randomization, consistent interval after that, just offset from other pipes
	if (pipe.timer === 0) {
		entities.push(makeDroplet({
			x: pipe.x,
			y: pipe.y,
		}));
	}
	if (pipe.timer <= 0) { // includes initial -1 for initial randomization
		pipe.timer = Math.floor(Math.random() * (maxDripPeriod - minDripPeriod)) + minDripPeriod;
	}
};

const simulateJump = (jump) => {
	jump.animationFrame += 1;
	if (jump.animationFrame >= 5) {
		jump.animationFrame = 0;
		jump.active = false;
	}
};

const notDropletOrJunkbot = (entity) => entity.type !== "droplet" && entity.type !== "junkbot";
const simulateTeleport = (teleport) => {
	if (teleport.timer > 0) {
		teleport.timer -= 1;
	}
	const targetTeleport = findLinkedTeleport(teleport);
	teleport.blocked =
		!targetTeleport ||
		rectangleCollisionTest(teleport.x + 15, teleport.y - 18 * 4, 15 * 2, 18 * 4, notDropletOrJunkbot) ||
		rectangleCollisionTest(targetTeleport.x + 15, targetTeleport.y - 18 * 4, 15 * 2, 18 * 4, notDropletOrJunkbot);
	if (teleport.timer > TELEPORT_COOLDOWN - TELEPORT_EFFECT_PERIOD) {
		teleportEffects.push({
			x: teleport.x + 15,
			y: teleport.y, // - 18 * 4,
			frameIndex: (teleport.timer % 3),
		});
	}
};

// #endregion
//
// █████ █████ █ █ █ ███ █   █ ████            █████ █     █████ █   █ ████  █████ █████ █   █
// █   █ █     █ █ █  █  ██  █ █   █     █     █   █ █     █   █ █   █ █   █ █   █ █     █  █
// █████ █████ █ █ █  █  █ █ █ █   █    ███    █████ █     █████  █ █  █████ █████ █     ███
// █  █  █     █ █ █  █  █  ██ █   █     █     █     █     █   █   █   █   █ █   █ █     █  █
// █  ██ █████  █ █  ███ █   █ ████            █     █████ █   █   █   ████  █   █ █████ █   █
//
//               ▄█      ▄█       .--.--------.--.       █▄      █▄
//             ▄███    ▄███       |  ._.    ._D90|       ███▄    ███▄
//           ▄█████  ▄█████       | ((_))  ((_)) |       █████▄  █████▄
//          ▀██████ ▀██████       |  `_______-'  |       ██████▀ ██████▀
//            ▀████   ▀████       [()/o      o\()]       ████▀   ████▀
//              ▀██     ▀██       "-'--"----"--`-"       ██▀     ██▀
//                ▀       ▀                              ▀       ▀
// #region Rewind + Playback

// Helps to detect desynchronization between playback and recording.
// There's also a separate detection in the problem detection code.
const findMisplacedEntities = (withinEntities, compareToEntities) => {
	return withinEntities.filter((entity) => {
		for (const compareToEntity of compareToEntities) {
			if (
				entity.type === compareToEntity.type &&
				(
					(entity.grabbed &&
						compareToEntity.grabbed) ||
					(entity.x === compareToEntity.x &&
						entity.y === compareToEntity.y)
				)
			) {
				return false;
			}
		}
		return true;
	});
};

let pausedForRewind = false;
const rewindRate = 2;
const handleRewind = () => {
	// rewind, like in Braid etc.
	if (keys.ShiftLeft || keys.ShiftRight || rewindingWithButton) {
		if (!paused) {
			pausedForRewind = true;
			paused = true;
			playbackLevel = diffPatcher.clone(currentLevel); // HACK
		}
		if (frameCounter > 0) {
			// playSound("undo", 0.3, 0.6);
			playSound("undo", 0.1, 0.6);
			// playSound("turn", 0.3, 0.5);
			// playSound("jump", 0.3, 0.8);
			// playSound("rustle0", 0.1, 0.8);
		}
		// sort for consistency for level delta patching
		entities.sort((a, b) => a.id - b.id);
		for (let i = 0; i < rewindRate; i++) {
			frameCounter -= 1;
			if (frameCounter < 0) {
				frameCounter = 0;
			} else if (frameCounter > 0) { // don't undo level initialization
				// handle playbackLevel and currentLevel separately, as
				// they could have their own notions of what's going on
				for (const event of playthroughEvents) {
					if (event.t === frameCounter + 1) {
						diffPatcher.unpatch(currentLevel, event.levelPatch);
					}
				}
				for (const event of playbackEvents) {
					if (event.t === frameCounter + 1) {
						diffPatcher.unpatch(playbackLevel, event.levelPatch);
					}
				}
			}
		}
	} else if (pausedForRewind) {
		pausedForRewind = false;
		paused = false;
	}
};
const handlePlayback = () => {
	debug("handlePlayback: frameCounter", frameCounter);
	for (const event of playbackEvents) {
		if (event.t === frameCounter + 1) {
			if (event.levelPatch) {
				// sort for consistency for level delta patching
				playbackLevel.entities?.sort((a, b) => a.id - b.id);
				diffPatcher.patch(playbackLevel, event.levelPatch);

				// compare level state to see if it's desynchronized
				if (currentLevel.name !== playbackLevel.name) {
					desynchronized = true;
					paused = true;
					showErrorMessage("Wrong level for playback.");
					return;
				}
				const misplacedInSimulation = findMisplacedEntities(entities, playbackLevel.entities);
				const misplacedInRecording = findMisplacedEntities(playbackLevel.entities, entities);
				if (misplacedInSimulation.length || misplacedInRecording.length) {
					desynchronized = true;
					for (const entity of [...misplacedInSimulation, ...misplacedInRecording]) {
						entity.misplaced = true;
					}
					// paused = true;
					// showErrorMessage("Desynchronized playback.");
					// return;
				}
			}

			const playbackMouse = { worldX: event.x, worldY: event.y };
			const { x, y } = worldToCanvas(playbackMouse.worldX, playbackMouse.worldY);
			playbackMouse.x = x;
			playbackMouse.y = y;
			if (event.type === "pickup") {
				const grabs = possibleGrabs(playbackMouse);
				if (grabs && !dragging.length) {
					if (event.grabType === "upward") {
						startGrab(grabs.upward, { grabType: "upward", duringPlayback: true, mouse: playbackMouse });
					} else if (event.grabType === "downward") {
						startGrab(grabs.downward, { grabType: "downward", duringPlayback: true, mouse: playbackMouse });
					} else {
						startGrab(grabs[0], { grabType: "single", duringPlayback: true, mouse: playbackMouse });
					}
					// @TODO: this should play a sound, right?
					// playSound("blockClick");
				} else {
					// showErrorMessage("Something must be different between recording and playback.");
					desynchronized = true;
				}
			} else if (event.type === "place") {
				updateDrag(playbackMouse);
				finishDrag({ duringPlayback: true, mouse: playbackMouse });
			} else if (event.type === "pointer") {
				updateDrag(playbackMouse);
				mouse.worldX = playbackMouse.worldX;
				mouse.worldY = playbackMouse.worldY;
				mouse.x = playbackMouse.x;
				mouse.y = playbackMouse.y;
				playthroughEvents.push(event); // preserve this information in case of re-saving a recording from playback
			}
		}
	}
};

// #endregion
//                                                       ▄
// █████ ███ █   █ █   █ █     █████ █████ █████ █       ███▄▄
// █      █  ██ ██ █   █ █     █   █   █   █     █       ███████▄▄
// █████  █  █ █ █ █   █ █     █████   █   █████ █       █████████▀▀
//     █  █  █   █ █   █ █     █   █   █   █             █████▀▀
// █████ ███ █   █ █████ █████ █   █   █   █████ █       █▀▀
//
// #region Simulate! (simulation main)

const simulate = (entities) => {
	frameCounter += 1;

	updateAccelerationStructures();

	// sort for gravity
	entities.sort((a, b) => b.y - a.y);

	simulateGravity();

	teleportEffects.length = 0;

	for (const entity of entities) {
		if (!entity.grabbed) {
			if (entity.type === "junkbot") {
				simulateJunkbot(entity);
			} else if (entity.type === "gearbot") {
				simulateGearbot(entity);
			} else if (entity.type === "climbbot") {
				simulateClimbbot(entity);
			} else if (entity.type === "flybot") {
				simulateFlybot(entity);
			} else if (entity.type === "eyebot") {
				doEyebotMovement(entity);
			} else if (entity.type === "jump") {
				simulateJump(entity);
			} else if (entity.type === "teleport") {
				simulateTeleport(entity);
			} else if (entity.type === "pipe") {
				simulatePipe(entity);
			} else if (entity.type === "droplet") {
				simulateDroplet(entity);
			} else if (entity.type === "bin" && entity.scaredy) {
				simulateScaredy(entity);
			} else if ("animationFrame" in entity) {
				entity.animationFrame += 1;
			}
		}
	}
	// Remove entities with flag removeBeforeRender.
	// It's important not to remove entities while iterating over them,
	// as this can lead to entities not being simulated in a frame,
	// which can lead to entities offset from each other, which is problematic e.g. for "Ally" test level.
	{
		let iOut = 0;
		for (let i = 0; i < entities.length; i++) {
			if (!entities[i].removeBeforeRender) {
				entities[iOut] = entities[i];
				iOut += 1;
			}
		}
		entities.length = iOut;
	}

	for (const entity of entities) {
		if ("floating" in entity) {
			entity.wasFloating = entity.floating;
			delete entity.floating;
		}

		// eyebot targeting is done separately from movement, so that it can support
		// a flybot continuously blocking an eyebot's line of sight ("Ally" test case)
		if (entity.type === "eyebot") {
			doEyebotTargeting(entity);
		}
	}
	// wind and laser beams
	wind.length = 0;
	laserBeams.length = 0;
	for (const entity of entities) {
		if (entity.type === "fan" && entity.on) {
			const fan = entity;
			const extents = [];
			for (let x = fan.x + 15; x < fan.x + fan.width - 15; x += 15) {
				let extent = 0;
				for (let y = fan.y - 18; y > -200; y -= 18) {
					let collision = false;
					for (const otherEntity of entities) {
						if (!otherEntity.grabbed && rectanglesIntersect(
							x,
							y,
							15,
							18,
							otherEntity.x,
							otherEntity.y,
							otherEntity.width,
							otherEntity.height,
						)) {
							if (otherEntity.type === "junkbot") {
								if (!otherEntity.wasFloating) {
									playSound("fan");
								}
								otherEntity.floating = true;
							} else if (otherEntity.type !== "droplet") {
								collision = true;
								break;
							}
						}
					}
					if (collision) {
						break;
					}
					extent += 1;
				}
				extents.push(extent);
			}
			wind.push({ fan, extents });
		}
		if (entity.type === "laser" && entity.on) {
			const laserBrick = entity;
			// @TODO: collide with level bounds
			let extent = 0;
			let collision;
			for (; extent < 200; extent += 1) {
				const x = laserBrick.x +
					(laserBrick.facing === 1 ? laserBrick.width : -15) +
					15 * extent * laserBrick.facing;
				for (const otherEntity of entities) {
					if (!otherEntity.grabbed && rectanglesIntersect(
						x,
						laserBrick.y,
						15,
						18,
						otherEntity.x,
						otherEntity.y,
						otherEntity.width,
						otherEntity.height,
					)) {
						if (otherEntity.type === "junkbot") {
							hurtJunkbot(otherEntity, "laser");
						}
						if (otherEntity.type !== "droplet") {
							collision = otherEntity;
							break;
						}
					}
				}
				if (collision) {
					break;
				}
			}
			laserBeams.push({ laserBrick, extent, hitWhat: collision });
		}
	}
	for (const entity of entities) {
		delete entity.wasFloating;
	}

	// sort for consistency for level delta patching
	entities.sort((a, b) => a.id - b.id);
	playthroughEvents.push({
		type: "step",
		t: frameCounter,
		x: mouse.worldX,
		y: mouse.worldY,
		editing,
		levelPatch: diffPatcher.clone(diffPatcher.diff(levelLastFrame, currentLevel)),
	});
	levelLastFrame = diffPatcher.clone(currentLevel);
};

// #endregion
//                                                                                      .-"""-.
// █████ █████ █████ ████  █     █████ █   █    █████ █     █████ █   █ █████ █   █    //  .  \\
// █   █ █   █ █   █ █   █ █     █     ██ ██    █     █     █     █   █   █   █   █    |  /!\  ;
// █████ █████ █   █ █████ █     █████ █ █ █    █████ █     █████ █   █   █   █████    \\ ‾‾‾ //
// █     █  █  █   █ █   █ █     █     █   █        █ █     █     █   █   █   █   █      '--'( \
// █     █  ██ █████ ████  █████ █████ █   █    █████ █████ █████ █████   █   █   █           \ \
//                                                                                             \_)
// #region Problem Sleuth (issue detection)
// #@: problem detection, problem detector, issue detector, problem highlighting, problem highlighter, issue highlighting, issue highlighter

const detectProblems = () => {
	// active validity checking of the world

	const maxEntityHeight = 100;
	const reportedCollisions = new Map();
	const isNum = (value) => typeof value === "number" && isFinite(value);
	const problems = [];
	for (const entity of entities) {
		/* eslint-disable no-continue */
		if (!isNum(entity.x) || !isNum(entity.y)) {
			problems.push({ message: `Invalid position (x/y) for entity ${JSON.stringify(entity, null, "\t")}\n` });
			continue;
		}
		if (entity.x % 15 !== 0) {
			problems.push({ message: `x position not aligned to grid for entity ${JSON.stringify(entity, null, "\t")}\n` });
			continue;
		}
		if (!isNum(entity.width) || !isNum(entity.height)) {
			problems.push({ message: `Invalid size (width/height) for entity ${JSON.stringify(entity, null, "\t")}\n` });
			continue;
		}
		if (entity.type === "brick" && !isNum(entity.widthInStuds)) {
			problems.push({ message: `Invalid widthInStuds for entity ${JSON.stringify(entity, null, "\t")}\n` });
			continue;
		}
		if (entity.type === "brick" && entity.width !== 15 * entity.widthInStuds) {
			problems.push({ message: `width doesn't match widthInStuds * 15 for entity ${JSON.stringify(entity, null, "\t")}\n` });
			continue;
		}

		/* eslint-enable no-continue */
		for (const topY of Object.keys(entitiesByTopY).map(Number)) {
			if (
				topY < entity.y + entity.height &&
				topY + maxEntityHeight > entity.y
			) {
				for (const otherEntity of entitiesByTopY[topY]) {
					if (
						otherEntity !== entity &&
						(reportedCollisions.get(entity) || []).indexOf(otherEntity) === -1 &&
						(reportedCollisions.get(otherEntity) || []).indexOf(entity) === -1
					) {
						if (
							rectanglesIntersect(
								entity.x,
								entity.y,
								entity.width,
								entity.height,
								otherEntity.x,
								otherEntity.y,
								otherEntity.width,
								otherEntity.height,
							)
						) {
							const worldX = (entity.x + otherEntity.x + (entity.width + otherEntity.width) / 2) / 2;
							const worldY = (entity.y + otherEntity.y + (entity.height + otherEntity.height) / 2) / 2;
							problems.push({ message: `${entity.type} to ${otherEntity.type} collision`, worldX, worldY });
							if (reportedCollisions.has(entity)) {
								reportedCollisions.get(entity).push(otherEntity);
							} else {
								reportedCollisions.set(entity, [otherEntity]);
							}
							if (reportedCollisions.has(otherEntity)) {
								reportedCollisions.get(otherEntity).push(entity);
							} else {
								reportedCollisions.set(otherEntity, [entity]);
							}
						}
					}
				}
			}
		}
	}
	// also detect problems with desynchronization between the recording and playback
	if (playbackLevel?.entities) {
		for (const entity of entities) {
			let found = false;
			for (const recordedEntity of playbackLevel.entities) {
				if (entity.id === recordedEntity.id) {
					found = true;
					if (
						entity.x !== recordedEntity.x ||
						entity.y !== recordedEntity.y ||
						entity.animationFrame !== recordedEntity.animationFrame
					) {
						problems.push({ message: `${entity.type} desynchronized\n(ID: ${entity.id})`, worldX: entity.x, worldY: entity.y });
					}
					break;
				}
			}
			if (!found) {
				problems.push({ message: `${entity.type} not found in recording, but exists in simulation\n(ID: ${entity.id})`, worldX: entity.x, worldY: entity.y });
			}
		}
		for (const recordedEntity of playbackLevel.entities) {
			let found = false;
			for (const entity of entities) {
				if (recordedEntity.id === entity.id) {
					found = true;
					break;
				}
			}
			if (!found) {
				problems.push({ message: `${recordedEntity.type} not found in simulation, but exists in recording\n(ID: ${recordedEntity.id})`, worldX: recordedEntity.x, worldY: recordedEntity.y + 8 });
			}
		}
	}

	return problems;
};

// #endregion
//
// █████ █   █ ███ █   █ █████ █████ ███ █████ █   █    [◢◼◤◢◼◤◢◼◤◢◼◤]
// █   █ ██  █  █  ██ ██ █   █   █    █  █   █ ██  █    [◥◼◣◥◼◣◥◼◣◥◼◣]
// █████ █ █ █  █  █ █ █ █████   █    █  █   █ █ █ █    |__Junkbot_3_|
// █   █ █  ██  █  █   █ █   █   █    █  █   █ █  ██    |SN_|TK_|RL__|
// █   █ █   █ ███ █   █ █   █   █   ███ █████ █   █    |____2022____|
//
// #region Animation

let rafid;
window.addEventListener("error", () => {
	// so my computer doesn't freeze up from the console logging messages about repeated errors
	cancelAnimationFrame(rafid);
});
const controlViewport = () => {
	if (!keys.ControlLeft && !keys.ControlRight && !keys.AltLeft && !keys.AltRight) {
		if (keys.KeyW || keys.ArrowUp) {
			viewport.centerY -= 20;
		}
		if (keys.KeyS || keys.ArrowDown) {
			viewport.centerY += 20;
		}
		if (keys.KeyA || keys.ArrowLeft) {
			viewport.centerX -= 20;
		}
		if (keys.KeyD || keys.ArrowRight) {
			viewport.centerX += 20;
		}
	}
	if (pointerEventCache.length < 2 && enableMarginPanning) {
		const panMarginSize = Math.min(innerWidth, innerHeight) * 0.07;
		const panFromMarginSpeed = 10 * document.hasFocus();
		if (mouse.y < panMarginSize) {
			viewport.centerY -= panFromMarginSpeed;
		}
		if (mouse.y > canvas.height - panMarginSize) {
			viewport.centerY += panFromMarginSpeed;
		}
		if (mouse.x < panMarginSize + (editorUI.hidden ? 0 : editorUI.offsetWidth * window.devicePixelRatio)) {
			viewport.centerX -= panFromMarginSpeed;
		}
		if (mouse.x > canvas.width - panMarginSize - (testsUI.hidden ? 0 : testsUI.offsetWidth * window.devicePixelRatio)) {
			viewport.centerX += panFromMarginSpeed;
		}
	}
	// limit viewport
	if (currentLevel.bounds) {
		viewport.centerY = Math.min((currentLevel.bounds.y + currentLevel.bounds.height - 36) + canvas.height / 2 / viewport.scale, viewport.centerY);
		viewport.centerY = Math.max((currentLevel.bounds.y + 36) - canvas.height / 2 / viewport.scale, viewport.centerY);
		viewport.centerX = Math.min((currentLevel.bounds.x + currentLevel.bounds.width - 30) + canvas.width / 2 / viewport.scale, viewport.centerX);
		viewport.centerX = Math.max((currentLevel.bounds.x + 30) - canvas.width / 2 / viewport.scale, viewport.centerX);
	}
};
const render = () => {

	sortEntitiesForRendering(entities);

	const hovered = dragging.length ? [] : possibleGrabs();

	if (paused && !editing) {
		canvas.style.cursor = "default";
	} else if (dragging.length) {
		updateDrag(mouse);
		canvas.style.cursor = `url("images/cursors/cursor-grabbing.png") 8 8, grabbing`;
	} else if (hovered.length >= 2) {
		canvas.style.cursor = `url("images/cursors/cursor-grab-either.png") 8 8, grab`;
	} else if (hovered.upward) {
		canvas.style.cursor = `url("images/cursors/cursor-grab-upward.png") 8 8, grab`;
	} else if (hovered.downward) {
		canvas.style.cursor = `url("images/cursors/cursor-grab-downward.png") 8 8, grab`;
	} else if (hovered.length) {
		canvas.style.cursor = `url("images/cursors/cursor-grab.png") 8 8, grab`;
	} else {
		canvas.style.cursor = "default";
	}

	if (currentLevel.title === "Title Screen") {
		viewport.centerX = titleScreen.offsetWidth / 2 - 1;
		viewport.centerY = titleScreen.offsetHeight / 2 - 26;
		viewport.scale = window.devicePixelRatio;
	}

	// Note: while zooming, innerWidth * window.devicePixelRatio often stays the same, while both factors change
	if (
		canvas.width !== innerWidth * window.devicePixelRatio ||
		canvas.height !== innerHeight * window.devicePixelRatio ||
		canvas.style.width !== `${innerWidth}px` ||
		canvas.style.height !== `${innerHeight}px`
	) {
		canvas.width = innerWidth * window.devicePixelRatio;
		canvas.height = innerHeight * window.devicePixelRatio;
		canvas.style.width = `${innerWidth}px`;
		canvas.style.height = `${innerHeight}px`;
	}
	ctx.fillStyle = "#bbb";
	ctx.fillRect(0, 0, canvas.width, canvas.height);

	ctx.save(); // world viewport
	ctx.translate(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2));
	ctx.scale(viewport.scale, viewport.scale);
	ctx.translate(-Math.floor(viewport.centerX), -Math.floor(viewport.centerY));
	ctx.imageSmoothingEnabled = false;

	drawDecal(ctx, -6, -25, currentLevel.backdropName || "bkg1", currentLevel.game);
	if (currentLevel.backgroundDecals) {
		for (const { x, y, name } of currentLevel.backgroundDecals) {
			drawDecal(ctx, x - 3, y - 20, name, currentLevel.game);
		}
	}
	if (currentLevel.decals) {
		for (const { x, y, name } of currentLevel.decals) {
			drawDecal(ctx, x - 15 * 2, y - 64, name, currentLevel.game);
		}
	}
	if (currentLevel.title === "Title Screen") {
		ctx.save();
		ctx.translate(-1, -26 + 1); // -1, -26 = offset of level top/left compared to title screen frame image; +1 = unknown
		// (offsets of 1 might actually be due to differences in rounding when centering...?)
		// Now we can position things relative to title screen frame image, which is easier than doing it relative to the obscured level boundary.
		ctx.drawImage(resources.titleScreenWelcomePanel, 41, 206);
		const lines = [
			["Welcome to the factory", "black", 115, 217],
			["Try moving the colored bricks", "white", 73, 235],
			["with the mouse", "white", 164, 253],
			["before you play the game", "black", 104, 271],
		];
		for (const [text, color, x, y] of lines) {
			ctx.save();
			ctx.translate(x, y);
			ctx.scale(2, 2);
			drawText(ctx, text, 0, 0, color, "transparent", false);
			ctx.restore();
		}
		ctx.restore();
	}

	const shouldHilight = (entity) => {
		return editing ? entity.selected : entity.misplaced;
	};

	const placeable = canRelease();

	// ctx.save();
	// ctx.translate(-6.5, -15);
	// ctx.scale(0.206, 0.206);
	// ctx.drawImage(testVideo, 0, 0);
	// ctx.restore();

	if (playbackLevel?.entities && showDebug) {
		sortEntitiesForRendering(playbackLevel.entities);
		ctx.save();
		ctx.translate(12, -12);
		for (const entity of playbackLevel.entities) {
			if (entity.grabbed) {
				ctx.globalAlpha = placeable ? 0.8 : 0.3;
			}
			drawEntity(ctx, entity, shouldHilight(entity));
			ctx.globalAlpha = 1;
		}
		ctx.restore();
	}
	for (const entity of entities) {
		if (entity.grabbed) {
			ctx.globalAlpha = placeable ? 0.8 : 0.3;
		}
		drawEntity(ctx, entity, shouldHilight(entity));
		ctx.globalAlpha = 1;
	}
	for (const { fan, extents } of wind) {
		drawWind(ctx, fan, extents);
	}
	for (const { laserBrick, extent, hitWhat } of laserBeams) {
		drawLaserBeam(ctx, laserBrick, extent, hitWhat);
	}
	for (const { x, y, frameIndex } of teleportEffects) {
		drawTeleportEffect(ctx, x, y, frameIndex);
	}

	// draw connections between switches and controlled entities
	let showConnections = false;
	if (editing && (hovered.length || dragging.length)) {
		const hoveredBrick = brickAt(mouse, { includeFixed: true });
		showConnections = hoveredBrick && ("switchID" in hoveredBrick || "teleportID" in hoveredBrick);
	}
	if (showConnections) {
		smoothedSwitchConnectionAlpha += (1 - smoothedSwitchConnectionAlpha) * 0.6;
	} else {
		smoothedSwitchConnectionAlpha += (0 - smoothedSwitchConnectionAlpha) * 0.01;
	}
	if (smoothedSwitchConnectionAlpha > 0.01) {
		ctx.globalAlpha = smoothedSwitchConnectionAlpha * 0.5;
		for (const entity of entities) {
			if (entity.type === "switch") {
				for (const otherEntity of entities) {
					if (otherEntity.type !== "switch" && otherEntity.switchID === entity.switchID) {
						drawSwitchConnection(ctx, entity, otherEntity);
					}
				}
			}
			if (entity.type === "teleport") {
				for (const otherEntity of entities) {
					if (otherEntity !== entity && otherEntity.teleportID === entity.teleportID) {
						drawTeleportConnection(ctx, entity, otherEntity);
					}
				}
			}
		}
		ctx.globalAlpha = 1;
	}

	// draw playback mouse
	let playbackEvent;
	for (const event of playbackEvents) {
		if (event.t > frameCounter + 1) {
			playbackEvent = event;
			break;
		}
	}
	if (playbackEvent) {
		ctx.fillStyle = "white";
		ctx.strokeStyle = "black";
		ctx.lineWidth = 2;
		ctx.beginPath();
		ctx.moveTo(playbackEvent.x, playbackEvent.y);
		ctx.lineTo(playbackEvent.x, playbackEvent.y + 20);
		ctx.lineTo(playbackEvent.x + 15, playbackEvent.y + 14);
		ctx.closePath();
		ctx.fill();
		ctx.stroke();
	}

	if (selectionBox) {
		ctx.save();
		ctx.beginPath();
		if (viewport.scale === 1) {
			ctx.translate(0.5, 0.5);
		}
		ctx.rect(selectionBox.x1, selectionBox.y1, selectionBox.x2 - selectionBox.x1, selectionBox.y2 - selectionBox.y1);
		ctx.fillStyle = "rgba(0, 155, 255, 0.1)";
		ctx.strokeStyle = "rgba(0, 155, 255, 0.8)";
		ctx.lineWidth = 1 / viewport.scale;
		ctx.fill();
		ctx.stroke();
		ctx.restore();
	}

	ctx.strokeStyle = "black";
	ctx.lineWidth = editing ? 1 : 10000;
	const { bounds } = currentLevel;
	if (bounds) {
		ctx.strokeRect(bounds.x - ctx.lineWidth / 2, bounds.y - ctx.lineWidth / 2, bounds.width + ctx.lineWidth, bounds.height + ctx.lineWidth);
	}

	if (showDebug) {
		ctx.strokeStyle = "#f0f";
		ctx.lineWidth = 1;
		for (const { x, y, width, height } of debugWorldSpaceRects) {
			ctx.strokeRect(x - 0.5, y - 0.5, width, height);
		}
	}
	debugWorldSpaceRects.length = 0;

	ctx.restore(); // world viewport

	if (desynchronized && !playbackLevel) {

		// VHS effect
		const topLeft = worldToCanvas(bounds.x, bounds.y);
		const bottomRight = worldToCanvas(bounds.x + bounds.width, bounds.y + bounds.height);
		const width = Math.min(bottomRight.x - topLeft.x, canvas.width - topLeft.x);
		const height = Math.min(bottomRight.y - topLeft.y, canvas.height - topLeft.y);
		const imageData = ctx.getImageData(topLeft.x, topLeft.y, width, height);
		if (width && height) {
			for (let i = 0, j = 0; i < imageData.data.length; i += 4, j += 4) {
				if (Math.random() > 0.9999) {
					j += 4;
				}
				if (Math.random() > 0.99999) {
					j -= 80;
				}
				imageData.data[i] = imageData.data[j];
				imageData.data[i + 1] = imageData.data[j + 1];
				imageData.data[i + 2] = imageData.data[j + 2];
				imageData.data[i + 3] = imageData.data[j + 3];
			}
			ctx.putImageData(imageData, topLeft.x, topLeft.y);
		}
		if (Math.random() > 0.7) {
			playSound("deathByWater");
		} else {
			playSound("rustle0");
		}
	}

	if (showDebug) {

		debug("FONT CHARACTERS", `${fontChars}\n${fontChars.split("").join("  ")}`);
		debug("TOTAL ENTITIES", entities.length);
		debug("VIEWPORT POSITION", `${viewport.centerX}, ${viewport.centerY}`);
		debug("VIEWPORT SCALE", `${viewport.scale}X`);

		const problems = detectProblems();
		debug("TOTAL PROBLEMS", problems.length);
		for (const key of Object.keys(debugs)) {
			if (key.match(/^PROBLEM/i)) {
				delete debugs[key];
			}
		}
		for (const { worldX, worldY, message } of problems) {
			if (worldX !== undefined) {
				let { x, y } = worldToCanvas(worldX, worldY);
				x = Math.floor(x);
				y = Math.floor(y);
				y -= 5;
				if (x < 0) {
					x = 0;
				}
				if (y < 0) {
					y = 0;
				}
				if (x > canvas.width - 150) {
					x = canvas.width - 150;
				}
				if (y > canvas.height - 10) {
					y = canvas.height - 10;
				}
				drawText(ctx, message, x, y, "white");
			} else {
				debug(`PROBLEM - ${message}`);
			}
		}

		let debugText = `Toggle debug with grave accent \` / tilde ~ key.
Lines marked with [?] may be outdated for this frame.

`;
		for (const [subject, { text, time }] of Object.entries(debugs)) {
			debugText += `[${time === frameStartTime ? " " : "?"}] ${subject}${text ? `: ${text}` : ""}\n`;
		}
		const x = 1 + editorUI.offsetWidth;
		const y = 1 + mainControlsBar.offsetHeight + editorControlsBar.offsetHeight;
		drawText(ctx, debugText, x, y, "white");

		const hoveredBrick = brickAt(mouse, { includeFixed: true });
		if (dragging.length) {
			drawText(ctx, `DRAGGING: ${JSON.stringify(dragging, null, "\t")}`, mouse.x + 50, mouse.y - 30, "white");
			// } else if (hovered.length) {
			// 	drawText(ctx, `HOVERED: ${JSON.stringify(hovered, null, "\t")}`, mouse.x + 50, mouse.y - 30, "white");
		} else if (hoveredBrick) {
			drawText(ctx, `HOVERED: ${JSON.stringify(hoveredBrick, null, "\t")}`, mouse.x + 50, mouse.y - 30, "white");
		}
	}
};
const checkLevelEnd = () => {
	if (winOrLose() !== winLoseState) {
		winLoseState = winOrLose();
		if (winLoseState === "lose" && !paused) {
			paused = true;
			if (!testing) {
				// eslint-disable-next-line no-use-before-define
				showLevelLoseUI();
				setTimeout(() => {
					playSound(Math.random() < 0.5 ? "ouch" : "uhoh");
				}, 1000);
			}
		}
		if (winLoseState === "win" && !paused) {
			paused = true;
			if (!testing) {
				const timeSinceCollectBin = Date.now() - collectBinTime;
				const levelAtWin = currentLevel;
				setTimeout(() => {
					if (currentLevel !== levelAtWin) {
						return; // especially for while running tests and clicking on a test to go to
					}
					playSound("ohYeah");
					try {
						// don't save score for edited levels (in particular, don't save over the score for the real levels! cheating!)
						if (currentLevel.title && parseRoute(location.hash).game !== GAME_USER_CREATED) {
							const scoreKey = storageKeys.score(currentLevel.title);
							const solutionKey = storageKeys.solutionRecording(currentLevel.title);
							const formerFewest = Number(localStorage[scoreKey]);
							if (!isFinite(formerFewest) || formerFewest >= moves) {
								localStorage[scoreKey] = moves;
								// save playthrough for playback (for enjoyment and TESTING),
								// and possible future server-verification
								// Don't save over if replaying a solution. (Maybe should extend to the score as well...)
								if (playbackEvents.length === 0) {
									// I've disabled "step" events because they take up too much space, right now.
									// They cause localStorage to be filled up and then you can't even save your level progress (scores).
									localStorage[solutionKey] = JSON.stringify(playthroughEvents.filter(({ type }) => type !== "step"));
									// eslint-disable-next-line no-console
									console.log("Saved solution for", currentLevel.title);
								}
							} else {
								// eslint-disable-next-line no-console
								console.log("Not saving solution for", currentLevel.title, "since it's not better than", formerFewest, "moves. New solution was:", JSON.stringify(playthroughEvents));
							}
						}
					} catch (error) {
						showErrorMessage("Couldn't save level progress.\nAllow local storage (sometimes called 'cookies') to save progress.", error);
						// eslint-disable-next-line no-console
						console.log("New solution was:", JSON.stringify(playthroughEvents));
					}
					// eslint-disable-next-line no-use-before-define
					showLevelWinUI();
				}, Math.max(resources.collectBin.duration, resources.collectBin2.duration) * 1000 - timeSinceCollectBin);
			}
		}
	}
};
const animate = () => {
	rafid = requestAnimationFrame(animate);

	frameStartTime = Date.now();

	controlViewport();
	updateMouseWorldPosition(); // (with new viewport)

	handleRewind();
	if (!paused) {
		handlePlayback(); // before frameCounter += 1 in simulate, so playback can handle level initialization (for playbackLevel, not the main currentLevel)
		// also note that handlePlayback can pause in some cases!
	}

	// run the simulation
	if (!paused) {
		const now = performance.now();
		const timeSinceLastSimulate = now - lastSimulateTime;
		debug("TIME SINCE LAST SIMULATE", timeSinceLastSimulate);
		if (timeSinceLastSimulate >= 1000 / targetFPS) {
			debug("REMAINDER MILLISECONDS", timeSinceLastSimulate - 1000 / targetFPS);
			simulate(entities);
			smoothedFrameTime += (timeSinceLastSimulate - smoothedFrameTime) / fpsSmoothing;
			lastSimulateTime = now;
		}
		const smoothedFPS = 1000 / smoothedFrameTime;
		debug("SIMULATION FPS", smoothedFPS.toFixed(0));
		debug("TARGET FPS", targetFPS);
	} else {
		updateAccelerationStructures(); // also within simulate()
	}

	checkLevelEnd();

	render();
};

// #endregion
//                      ______________________
// █████ █   █ ███     |• __________________ •|
// █     █   █  █      | |  This will make  | |
// █ ███ █   █  █      | |  it interactive! | |
// █   █ █   █  █      | |      [ OK ]      | |
// █████ █████ ███     |• ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ •|
//                      ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
// #region GUI (Graphical User Interface)

const toggleInfoBox = () => {
	infoBox.hidden = !infoBox.hidden;
	toggleInfoButton.setAttribute("aria-expanded", infoBox.hidden ? "false" : "true");
};

let playedJunkbotIntro = false;
let playedJunkbotUndercoverIntro = false;
const ruffle = window.RufflePlayer.newest();
let rufflePlayer;
const stopIntro = () => {
	introContainer.hidden = true;
	skipIntroButton.hidden = true;
	resetScreenButton.hidden = false;
	rufflePlayer?.destroy(); // there is no stop or rewind method
	rufflePlayer?.remove();
	rufflePlayer = null;

	const { game } = parseRoute(location.hash);
	junkbotUndercoverTitle.hidden = game !== GAME_JUNKBOT_UNDERCOVER;
};
const hideTitleScreen = () => {
	titleScreen.hidden = true;
	stopIntro();
};
const showTitleScreen = (showIntro) => {

	// don't show editor UI on the title screen!
	if (editing) {
		toggleEditing();
	}
	// @TODO: pause while intro plays, so the squeaky turning sound effect doesn't play!
	paused = false;

	titleScreen.hidden = false;
	junkbotUndercoverTitle.hidden = true;
	initLevel(resources.titleScreenLevel);
	titleScreen.classList.add("title-screen-level-loaded");
	const { game } = parseRoute(location.hash);
	if (game === GAME_JUNKBOT) {
		showIntro ??= !playedJunkbotIntro;
	} else if (game === GAME_JUNKBOT_UNDERCOVER) {
		showIntro ??= !playedJunkbotUndercoverIntro;
	} else {
		showIntro = false;
	}
	if (showIntro) {
		if (game === GAME_JUNKBOT) {
			playedJunkbotIntro = true;
		} else if (game === GAME_JUNKBOT_UNDERCOVER) {
			playedJunkbotUndercoverIntro = true;
		}
		replayIntroButton.hidden = false;
		rufflePlayer = ruffle.createPlayer();
		rufflePlayer.classList.toggle("metal-border", game === GAME_JUNKBOT);
		introContainer.appendChild(rufflePlayer);
		introContainer.classList.toggle("undercover-intro", game === GAME_JUNKBOT_UNDERCOVER);
		const swf = game === GAME_JUNKBOT_UNDERCOVER ? "flash/junkbot_undercover_intro.swf" : "flash/junkbot_intro.swf";
		rufflePlayer.load(swf).then(() => {
			// Note: It may not actually be loaded!
			// @TODO: handle failing to load the SWF somehow? more monkey-patching?
			// rufflePlayer.readyState is 0 regardless until later...

			if (!window.monkeyPatchedRuffleLoaded) {
				showErrorMessage("A problem occurred with SWF integration. (If updating the Ruffle library, note that it was patched before!)");
			}
			// The Junkbot intro Flash animations execute some lingo code via URIs,
			// which Ruffle by default treats like any URI and does location.assign() here:
			// https://github.com/ruffle-rs/ruffle/blob/72a811ae2c5aef43144b2a95f0dcf2e72465e005/web/src/navigator.rs#L162
			// This causes a bewildering permission prompt to run xdg-open (at least for me on XFCE),
			// We need to intercept it also to handle the timed events.
			window.monkeyPatchedRuffleLocationAssign = (url) => {
				if (url === "lingo:glob.download_manager.animDone()") {
					stopIntro();
				} else if (url === "lingo:glob.jbxtitle_a.show()") {
					// @TODO: for Junkbot Undercover, split GAME_JUNKBOT part of title,
					// to show it at this time
				} else if (url === "lingo:glob.jbxtitle_b.show()") {
					junkbotUndercoverTitle.hidden = false;
				} else {
					// eslint-disable-next-line no-console
					console.warn("Prevented Ruffle's location.assign from loading", url);
				}
			};
			rufflePlayer.play();
			introContainer.hidden = false;
			skipIntroButton.hidden = false;
			resetScreenButton.hidden = true;
		}, (error) => {
			// Note: the promise doesn't reject if the Flash file is not found.
			stopIntro();
			// eslint-disable-next-line no-console
			console.error("Failed to load Flash movie with Ruffle:", error);
		});
	} else {
		resetScreenButton.hidden = false;
		junkbotUndercoverTitle.hidden = game !== GAME_JUNKBOT_UNDERCOVER;
	}
};

const showLevelSelectScreen = (game, levelGroupName) => {
	// don't show editor UI on the level select screen!
	if (editing) {
		toggleEditing();
	}
	paused = true;

	levelSelectScreen.hidden = false;

	let levelNamesToShow = [];
	let paginated = true;
	let levelGroupNumber = parseInt((levelGroupName ?? "").replace(/\D/g, ""), 10);
	if (isNaN(levelGroupNumber)) {
		levelGroupNumber = 1;
	}
	for (const list of getLevelLists(resources)) {
		if (gameNameToSlug(game) === gameNameToSlug(list.game)) {
			levelNamesToShow = list.levelNames.slice((levelGroupNumber - 1) * list.levelsPerPage, levelGroupNumber * list.levelsPerPage);
			if (list.levelsPerPage === Infinity) {
				paginated = false;
			}
			break;
		}
	}

	levelList.innerHTML = "";

	junkbotPagination.hidden = true;
	junkbotUndercoverPagination.hidden = true;
	if (game === GAME_JUNKBOT) {
		junkbotPagination.hidden = false;
	} else if (game === GAME_JUNKBOT_UNDERCOVER) {
		junkbotUndercoverPagination.hidden = false;
	}
	const tabs = (game === GAME_JUNKBOT ? junkbotPagination : junkbotUndercoverPagination).querySelectorAll(".level-group-tab");
	for (let i = 0; i < tabs.length; i++) {
		const tab = tabs[i];
		tab.classList.toggle("selected", i === levelGroupNumber - 1);
	}
	if (levelNamesToShow.length === 0) {
		showErrorMessage(`No levels found for game "${game}" and group "${levelGroupName}"`, {
			buttons: [
				{
					label: "Title Screen",
					isDefault: true,
					action: () => {
						location.hash = `#${gameNameToSlug(game)}`;
					},
				},
			],
		});
		return;
	}

	let n = 0;
	for (const levelName of levelNamesToShow) {
		n += 1;
		const li = document.createElement("li");
		li.className = "level-list-item";
		const a = document.createElement("a");
		if (paginated) {
			a.href = `#${gameNameToSlug(game)}/levels/${levelGroupToSlug(`${levelGroupNumber}`, game)}/${levelNameToSlug(levelName)}`;
		} else {
			a.href = `#${gameNameToSlug(game)}/levels/${levelNameToSlug(levelName)}`;
		}
		let completedInMoves;
		try {
			if (game !== GAME_USER_CREATED) {
				completedInMoves = localStorage[storageKeys.score(levelName)];
			}
		} catch (error) {
			// no score tracking :/
			// @TODO: unlock all levels if there's an error? um, once there's any locking.
		}
		const completed = typeof completedInMoves !== "undefined";

		const completedImg = document.createElement("img");
		completedImg.className = "level-list-item-completed-indicator";
		completedImg.src = completed ? "images/menus/checkbox_on.png" : "images/menus/checkbox_off.png";
		const goldAwardImg = document.createElement("img");
		goldAwardImg.src = "images/menus/check_light.png";
		goldAwardImg.hidden = true;
		goldAwardImg.className = "level-list-item-gold-award";
		const score = document.createElement("span");
		score.className = "level-list-item-score";
		score.textContent = completedInMoves;
		const title = document.createElement("span");
		title.className = "level-list-item-title";
		title.textContent = levelName;
		const ordinal = document.createElement("span");
		ordinal.className = "level-list-item-ordinal";
		ordinal.textContent = n;
		if (game !== GAME_USER_CREATED) {
			a.append(ordinal, completedImg, goldAwardImg, title, score);
		} else {
			a.append(ordinal, title);
		}

		if (completedInMoves) {
			loadLevelByName({ game, levelName }).then((level) => {
				const metPar = completedInMoves <= level.par;
				if (metPar) {
					goldAwardImg.hidden = false;
				}
			}, (error) => {
				// eslint-disable-next-line no-console
				console.error("Failed to load level for score display:", error);
			});
		}

		li.appendChild(a);
		levelList.appendChild(li);
	}
};
const hideLevelSelectScreen = () => {
	levelSelectScreen.hidden = true;
};

const getLevelSelectURL = () => {
	const { game, levelGroup } = parseRoute(location.hash);
	return `#${gameNameToSlug(game)}/levels${levelGroup ? `/${levelGroup}` : ""}`;
};
const getTitleScreenURL = () => {
	const { game } = parseRoute(location.hash);
	return `#${gameNameToSlug(game)}`;
};

const initGUI = () => {
	document.body.addEventListener("pointerdown", (event) => {
		const button = event.target.closest(".generic-sound");
		if (button) {
			playSound("buttonClick");
		}
	});

	// Title screen

	startGameButton.addEventListener("click", () => {
		location.hash = getLevelSelectURL();
	});
	showCreditsButton.addEventListener("click", () => {
		window.open("https://github.com/1j01/janitorial-android#credits");
	});
	skipIntroButton.addEventListener("click", () => {
		stopIntro();
	});
	replayIntroButton.addEventListener("click", () => {
		try {
			stopIntro();
		} catch (error) {
			// eslint-disable-next-line no-console
			console.error(error);
		}
		showTitleScreen(true);
	});
	resetScreenButton.addEventListener("click", () => {
		showTitleScreen(false);
	});

	// Level select screen

	backToTitleScreenButton.addEventListener("click", () => {
		location.hash = getTitleScreenURL();
	});

	document.addEventListener("click", (event) => {
		const tab = event.target.closest(".level-group-tab");
		if (tab && !tab.classList.contains("selected")) {
			// @TODO: check if the level group is locked (implement keycards system)
			playSound("tabSwitch");
			// playSound("tabLocked");
		}
	});

	levelList.addEventListener("click", (event) => {
		const a = event.target.closest("a");
		if (a) {
			playSound("enterLevel");
		}
	});

	// Main game controls bar

	toggleInfoButton.addEventListener("click", toggleInfoBox);

	toggleFullscreenButton.addEventListener("click", toggleFullscreen);
	toggleFullscreenButton.ariaPressed = false; // document.fullscreenElement unlikely to work when loading page
	addEventListener("fullscreenchange", () => {
		toggleFullscreenButton.ariaPressed = Boolean(document.fullscreenElement);
	});

	toggleMuteButton.addEventListener("click", () => toggleMute());
	updateMuteButton();

	toggleEditingButton.addEventListener("click", toggleEditing);
	updateEditingButton();

	volumeSlider.addEventListener("input", () => {
		setVolume(volumeSlider.valueAsNumber);
	});
	volumeSlider.valueAsNumber = mainGain.gain.value;

	zoomInButton.addEventListener("click", () => zoomIn());
	zoomOutButton.addEventListener("click", () => zoomOut());

	rewindButton.addEventListener("pointerdown", () => {
		rewindingWithButton = true;
	});
	addEventListener("pointerup", () => {
		rewindingWithButton = false;
	});

	backToLevelSelectButton.addEventListener("click", () => {
		location.hash = getLevelSelectURL();
	});

	// Part of editor UX but not editor GUI.
	// Should be supported before opening editor UI.

	canvas.addEventListener("dragover", (event) => event.preventDefault());
	canvas.addEventListener("dragenter", (event) => event.preventDefault());
	canvas.addEventListener("drop", (event) => {
		event.preventDefault();
		openFromFile(event.dataTransfer.files[0]);
	});

	// Info box buttons, generated from table cells
	for (const tr of controlsTableRows) {
		const [controlCell, actionCell] = tr.cells;
		const kbd = controlCell.querySelector("kbd");
		if (kbd) {
			// relying on a kbd representing whole key combos, and the order of modifiers, and lack of Alt/Meta/Super/Hyper
			const match = kbd.textContent.match(/(Ctrl\s*\+\s*)?(Shift\s*\+\s*)?(\S+)/);
			if (match) {
				const ctrlKey = Boolean(match[1]);
				const shiftKey = Boolean(match[2]);
				let key = match[3];
				if (key === "+") {
					key = "NumpadAdd";
				} else if (key === "-") {
					key = "NumpadSubtract";
				}
				const button = document.createElement("button");
				button.className = "generic-button";
				button.addEventListener("click", () => {
					canvas.dispatchEvent(new KeyboardEvent("keydown", { key, code: key, ctrlKey, shiftKey, bubbles: true }));
				});
				wrapContents(actionCell, button);
			}
		} else if (!controlCell.matches("th")) {
			// eslint-disable-next-line no-console
			console.warn("No keyboard shortcut for", actionCell.textContent, actionCell);
		}
	}
};

const getLevelLists = (resources) => {
	const localLevels = [];
	try {
		// look through localStorage to find levels
		for (const key of Object.keys(localStorage)) {
			if (key.startsWith(storageKeys.levelPrefix)) {
				// localLevels.push(key.replace(storageKeys.levelPrefix, ""));
				const name = JSON.parse(localStorage[key]).level.title;
				if (name) {
					localLevels.push(name);
				} else {
					// eslint-disable-next-line no-console
					console.warn("No name found in locally stored level", key, localStorage[key]);
				}
			}
		}
	} catch (error) {
		// eslint-disable-next-line no-console
		console.error(error);
	}

	return [
		{
			game: GAME_JUNKBOT,
			levelNames: resources.levelNames,
			levelsPerPage: 15,
		},
		{
			game: GAME_JUNKBOT_UNDERCOVER,
			levelNames: resources.levelNamesUndercover,
			levelsPerPage: 15,
		},
		{
			game: GAME_TEST_CASES,
			levelNames: tests.map((test) => test.name),
			levelsPerPage: Infinity,
		},
		{
			game: GAME_USER_CREATED,
			levelNames: localLevels,
			levelsPerPage: Infinity,
		},
	];
};

const whereLevelIsInTheGame = (level, game) => {
	const gameSlug = gameNameToSlug(game);
	const levelSlug = levelNameToSlug(level.title);
	for (const list of getLevelLists(resources)) {
		if (gameSlug === gameNameToSlug(list.game)) {
			for (let i = 0; i < list.levelNames.length; i++) {
				if (levelSlug === levelNameToSlug(list.levelNames[i])) {
					return {
						pageNumber: 1 + Math.floor(i / list.levelsPerPage),
						levelNumber: 1 + (i % list.levelsPerPage),
					};
				}
			}
			break;
		}
	}
};

const initLevelDropdown = () => {
	const option = document.createElement("option");
	option.textContent = "Custom World";
	option.value = "custom-world";
	option.defaultSelected = true;
	levelDropdown.append(option);
	for (const { game, levelNames } of getLevelLists(resources)) {
		const optgroup = document.createElement("optgroup");
		optgroup.label = game;
		optgroup.value = game; // gameNameToSlug(game);
		levelDropdown.append(optgroup);
		for (const levelName of levelNames) {
			const option = document.createElement("option");
			option.textContent = levelName;
			option.value = levelNameToSlug(levelName);
			optgroup.append(option);
		}
	}
	levelDropdown.onchange = () => {
		const option = levelDropdown.options[levelDropdown.selectedIndex];
		const optgroup = option.parentNode.matches("optgroup") ? option.parentNode : null;
		const gameSlug = optgroup ? gameNameToSlug(optgroup.value) : null;
		const levelSlug = levelDropdown.value;
		if (levelSlug === "custom-world") {
			return; // this is a placeholder option
		}
		location.hash = `#${gameSlug}/levels/${levelSlug}`;
	};
};
let initializedEditorUI = false;
const initEditorUI = () => {
	if (initializedEditorUI) {
		return;
	}
	initializedEditorUI = true;

	editorUI.hidden = !editing;
	editorControlsBar.hidden = !editing;

	initLevelDropdown();

	let hilitButton;
	const makeInsertEntityButton = (protoEntity) => {
		const getEntityCopy = () => JSON.parse(JSON.stringify(protoEntity));
		const button = document.createElement("button");
		const buttonCanvas = document.createElement("canvas");
		const buttonCtx = buttonCanvas.getContext("2d");
		button.style.margin = "0";
		button.style.padding = "1px 6px";
		button.style.borderWidth = "3px";
		button.style.borderStyle = "solid";
		button.style.borderColor = "transparent";
		button.style.backgroundColor = "black";
		button.style.cursor = "inherit";
		button.addEventListener("click", () => {
			for (const entity of entities) {
				delete entity.selected;
				delete entity.grabbed;
				delete entity.grabOffset;
			}
			const entity = getEntityCopy();
			pasteEntities([entity]);
			editorUI.style.cursor = "url(\"images/cursors/cursor-insert.png\") 0 0, default";
			if (hilitButton) {
				hilitButton.style.borderColor = "transparent";
			}
			button.style.borderColor = "yellow";
			hilitButton = button;
			playSound("insert");
			canvas.focus(); // for keyboard shortcuts
		});
		editorUI.addEventListener("mouseleave", () => {
			editorUI.style.cursor = "";
		});
		let previewEntity = getEntityCopy();
		previewEntity.isPreviewEntity = true;
		buttonCanvas.width = previewEntity.width + 15 * 1;
		buttonCanvas.height = previewEntity.height + 18 * 2;
		const drawPreview = () => {
			buttonCtx.clearRect(0, 0, buttonCanvas.width, buttonCanvas.height);
			buttonCtx.save();
			buttonCtx.translate(0, 28);
			const prevShowDebug = showDebug;
			showDebug = false;
			drawEntity(buttonCtx, previewEntity);
			if (previewEntity.type === "fan") {
				drawWind(buttonCtx, previewEntity, [3, 3]);
			}
			if (previewEntity.type === "laser") {
				drawLaserBeam(buttonCtx, previewEntity, [3, 3]);
			}
			if (previewEntity.type === "teleport") {
				if (previewEntity.timer > TELEPORT_COOLDOWN - TELEPORT_EFFECT_PERIOD) {
					drawTeleportEffect(buttonCtx, previewEntity.x + 15, previewEntity.y, previewEntity.timer % 3);
				}
			}
			showDebug = prevShowDebug;
			buttonCtx.restore();
		};
		drawPreview();
		let previewAnimIntervalID;
		button.addEventListener("mouseenter", () => {
			if (previewEntity.type === "jump") {
				previewEntity.active = true;
			}
			if (previewEntity.type === "teleport") {
				previewEntity.timer = TELEPORT_COOLDOWN;
			}
			previewAnimIntervalID = setInterval(() => {
				const prev = {
					x: previewEntity.x,
					y: previewEntity.y,
					muted,
					paused,
					showDebug,
					currentLevel,
					entities,
					wind,
					laserBeams,
					teleportEffects,
					entitiesByTopY,
					entitiesByBottomY,
					lastKeys,
					playthroughEvents,
					playbackEvents,
					levelLastFrame,
					frameCounter,
				};
				muted = true;
				paused = false;
				showDebug = false;
				entities = [];
				currentLevel = { entities };
				wind = [];
				laserBeams = [];
				teleportEffects = [];
				entitiesByTopY = {};
				entitiesByBottomY = {};
				lastKeys = new Map();
				playthroughEvents = [];
				playbackEvents = [];
				levelLastFrame = {};
				frameCounter = 0;
				simulate([previewEntity]);
				({
					muted,
					paused,
					showDebug,
					currentLevel,
					entities,
					wind,
					laserBeams,
					teleportEffects,
					entitiesByTopY,
					entitiesByBottomY,
					lastKeys,
					playthroughEvents,
					playbackEvents,
					levelLastFrame,
					frameCounter,
				} = prev);
				previewEntity.x = prev.x;
				previewEntity.y = prev.y;
				drawPreview();
			}, 1000 / 15);
		});
		button.addEventListener("mouseleave", () => {
			clearInterval(previewAnimIntervalID);
			previewEntity = getEntityCopy();
			drawPreview();
		});
		button.append(buttonCanvas);
		entitiesPalette.append(button);
		return button;
	};

	for (const colorName of brickColorNames) {
		for (const widthInStuds of brickWidthsInStuds) {
			makeInsertEntityButton(makeBrick({
				colorName,
				widthInStuds,
				fixed: colorName === "gray",
				x: 0,
				y: 0,
			}));
		}
	}

	makeInsertEntityButton(makeJunkbot({
		x: 0,
		y: 0,
		facing: 1,
	}));

	makeInsertEntityButton(makeBin({
		x: 0,
		y: 0,
	}));
	makeInsertEntityButton(makeBin({
		x: 0,
		y: 0,
		scaredy: true,
	}));

	makeInsertEntityButton(makeCrate({
		x: 0,
		y: 0,
	}));

	makeInsertEntityButton(makeFire({
		x: 0,
		y: 0,
		on: false,
		switchID: "switch1",
	}));
	makeInsertEntityButton(makeFire({
		x: 0,
		y: 0,
		on: true,
		switchID: "switch1",
	}));

	makeInsertEntityButton(makeFan({
		x: 0,
		y: 0,
		on: false,
		switchID: "switch1",
	}));
	makeInsertEntityButton(makeFan({
		x: 0,
		y: 0,
		on: true,
		switchID: "switch1",
	}));

	makeInsertEntityButton(makeLaser({
		x: 0,
		y: 0,
		on: true,
		switchID: "switch1",
		facing: 1,
	}));
	makeInsertEntityButton(makeLaser({
		x: 0,
		y: 0,
		on: true,
		switchID: "switch1",
		facing: -1,
	}));

	makeInsertEntityButton(makeTeleport({
		x: 0,
		y: 0,
		teleportID: "tele1",
		timer: 0,
	}));

	makeInsertEntityButton(makeJump({
		x: 0,
		y: 0,
		fixed: false,
	}));
	makeInsertEntityButton(makeJump({
		x: 0,
		y: 0,
		fixed: true,
	}));

	makeInsertEntityButton(makeSwitch({
		x: 0,
		y: 0,
		on: false,
		switchID: "switch1",
	}));
	makeInsertEntityButton(makeSwitch({
		x: 0,
		y: 0,
		on: true,
		switchID: "switch1",
	}));

	makeInsertEntityButton(makeShield({
		x: 0,
		y: 0,
		fixed: false,
	}));
	makeInsertEntityButton(makeShield({
		x: 0,
		y: 0,
		fixed: true,
	}));

	makeInsertEntityButton(makePipe({
		x: 0,
		y: 0,
	}));
	makeInsertEntityButton(makeDroplet({
		x: 0,
		y: 0,
	}));

	makeInsertEntityButton(makeGearbot({
		x: 0,
		y: 0,
		facing: 1,
	}));
	makeInsertEntityButton(makeClimbbot({
		x: 0,
		y: 0,
		facing: 1,
	}));
	makeInsertEntityButton(makeFlybot({
		x: 0,
		y: 0,
		facing: 1,
	}));
	makeInsertEntityButton(makeEyebot({
		x: 0,
		y: 0,
	}));

	let lastScrollSoundTime = Date.now(); // not 0 because a random scroll event happens on page load; don't want page load to make a sound
	entitiesScrollContainer.addEventListener("scroll", () => {
		if (Date.now() > lastScrollSoundTime + 200) {
			playSound(`rustle${Math.floor(Math.random() * numRustles)}`);
			lastScrollSoundTime = Date.now();
		}
	});

	saveButton.onclick = saveToFile;

	openButton.onclick = openFromFileDialog;

	// It's important that these do undoable() or save() because that makes it save the editorLevelState
	// so if you go into play mode and back into editing mode, it doesn't reset these fields.
	levelBoundsCheckbox.onchange = () => {
		undoable(() => {
			if (levelBoundsCheckbox.checked) {
				currentLevel.bounds = {
					x: 0,
					y: 0,
					width: 35 * 15,
					height: 22 * 18,
				};
			} else {
				currentLevel.bounds = null;
			}
		});
	};
	levelTitleInput.onchange = () => {
		// not undoable to avoid churning thru autosave slots
		// undoable(() => {
		currentLevel.title = levelTitleInput.value;
		// });
		// editorLevelState = serializeToJSON(currentLevel);
		save();
	};
	levelHintInput.onchange = () => {
		undoable(() => {
			currentLevel.hint = levelHintInput.value;
		});
	};
	levelParInput.onchange = () => {
		undoable(() => {
			currentLevel.par = levelParInput.valueAsNumber;
		});
	};

	updateEditorUIForLevelChange = (level) => {
		levelBoundsCheckbox.checked = level.bounds;
		levelTitleInput.value = level.title ?? "";
		levelHintInput.value = level.hint ?? "";
		levelParInput.value = level.par ?? "";
		const showLevelTitle = level.title && (level.title !== "Title Screen" || editing);
		const projectTitle = "Janitorial Android (HTML5 Junkbot Remake)";
		document.title = showLevelTitle ? `${level.title} - ${projectTitle}` : projectTitle;
	};
	updateEditorUIForLevelChange(currentLevel);

	for (const button of editorControlsBar.querySelectorAll("button")) {
		button.addEventListener("click", () => {
			// button.ariaKeyShortcuts isn't supported in Firefox.
			// Also, note that this only handles the first shortcut in the string,
			// and only syntax needed for the current set of buttons.
			const ariaKeyShortcuts = button.getAttribute("aria-keyshortcuts");
			const match = ariaKeyShortcuts?.match(/(Ctrl\s*\+\s*)?(Shift\s*\+\s*)?(\S+)/);
			if (match) {
				const ctrlKey = Boolean(match[1]);
				const shiftKey = Boolean(match[2]);
				let key = match[3];
				if (key === "+") {
					key = "NumpadAdd";
				} else if (key === "-") {
					key = "NumpadSubtract";
				}
				canvas.dispatchEvent(new KeyboardEvent("keydown", { key, code: key, ctrlKey, shiftKey, bubbles: true }));
			} else {
				showErrorMessage("Oops! Something went wrong. Please report this bug.");
			}
		});
	}
};

const showLevelLoseUI = () => {
	const messages = [
		"I knew that was going to happen.",
		"I hate mondays.",
		"Why me?",
	];
	const message = messages[Math.floor(Math.random() * messages.length)];
	const div = document.createElement("div");
	div.innerHTML = `
		<img src="images/menus/level_lose.png" draggable="false" class="level-lose-image">
		<p class="level-lose-message">${message}</p>
	`;
	nonErrorDialogs.push(showMessageBox([div], {
		buttons: [
			{
				label: "Select Level",
				action: () => {
					location.hash = getLevelSelectURL();
				},
			},
			{
				label: "Get Hint",
				action: () => {
					const heading = document.createElement("div"); // yep not semantic
					let positionInfo;
					try {
						positionInfo = whereLevelIsInTheGame(currentLevel)?.levelNumber;
					} catch (error) {
						// eslint-disable-next-line no-console
						console.error("Error looking up position of level within the game:", error);
					}
					if (positionInfo) {
						heading.textContent = `Level ${positionInfo.levelNumber} hint:`;
					} else {
						heading.textContent = "Hint:";
					}
					showMessageBox([heading, currentLevel.hint], {
						buttons: [
							{
								label: "OK",
								action: () => {
									// eslint-disable-next-line no-use-before-define
									loadFromHash();
									paused = false;
								},
								isDefault: true,
							}
						],
						className: "hint-dialog",
					});
				},
			},
			{
				label: "Try Again",
				action: () => {
					// eslint-disable-next-line no-use-before-define
					loadFromHash();
					paused = false;
				},
				isDefault: true,
			},
		],
		className: "level-lose",
	}));
};

const showGameWinUI = (game) => {
	const win = document.createElement("div");
	win.innerHTML = `
		<h1>You Win!</h1>
		<h2>You have completed all levels!</h2>
	`;
	const buttons = [
		{
			label: "Select Level",
			action: () => {
				location.hash = getLevelSelectURL();
			},
		},
	];
	if (game === GAME_JUNKBOT) {
		buttons.push({
			label: "Play Junkbot Undercover",
			action: () => {
				location.hash = "#junkbot2";
			},
		});
	}
	nonErrorDialogs.push(showMessageBox([win], { buttons, className: "game-win" }));
};

const canGoToNextLevel = () => {
	const { game, levelSlug } = parseRoute(location.hash);
	if (game && levelSlug && game !== GAME_USER_CREATED && game !== GAME_TEST_CASES) {
		return true;
	}
	return false;
};
const goToNextLevel = () => {
	if (canGoToNextLevel()) {
		for (const { game, levelNames } of getLevelLists(resources)) {
			const index = levelNames.map(levelNameToSlug).indexOf(levelNameToSlug(currentLevel.title));
			if (index !== -1) {
				const nextLevelName = levelNames[index + 1];
				if (nextLevelName) {
					location.hash = `#${gameNameToSlug(game)}/levels/${levelNameToSlug(nextLevelName)}`;
				} else {
					showGameWinUI(game);
				}
				return;
			}
		}
		showErrorMessage("Don't know how to go to next level from here.");
	} else {
		showErrorMessage("Can't go to next level.");
	}
};

const showLevelWinUI = () => {
	const h1 = document.createElement("h1");
	h1.textContent = "Level Complete!";
	nonErrorDialogs.push(showMessageBox([h1], {
		buttons: [
			{
				label: "Select Level",
				action: () => {
					location.hash = getLevelSelectURL();
				},
			},
			{
				label: canGoToNextLevel() ? "Next Level" : "Edit Level",
				action: canGoToNextLevel() ? goToNextLevel : toggleEditing,
				isDefault: true,
			},
		],
		className: "level-win",
	}));
};

// #endregion
//                                                                                 _               ___
// █████ █████ █████ █████    █████ █   █ █   █ █   █ █████ █████                _( }           __/<>/|______
//   █   █     █       █      █   █ █   █ ██  █ ██  █ █     █   █      -=   _  <<  \          _//[ON]////////_____       ___
//   █   █████ █████   █      █████ █   █ █ █ █ █ █ █ █████ █████          `.\__/`/\\       _//\____/ \____///////__    (___)
//   █   █         █   █      █  █  █   █ █  ██ █  ██ █     █  █     -=      '--'\\  `    _//\____/        \____/<>/|___|↱↴ |_
//   █   █████ █████   █      █  ██ █████ █   █ █   █ █████ █  ██         -=     //      //\____/               [ON]////(Ꝉ↲_)/
//                                                                               \)      \____/                  \____/\____/
// #region Test Runner

const testRouting = () => {
	for (const { hash, expected } of routingTests) {
		// const { game, levelName, levelSection, screen, canonicalHash, wantsEdit } = parseHash(hash);
		const actual = parseRoute(hash);
		const mismatched = Object.keys(expected).filter((key) => actual[key] !== expected[key]);
		if (mismatched.length) {
			// eslint-disable-next-line no-console
			console.warn(`Routing test failed for hash ${hash}\n`, ...mismatched.map((key) => `"${key}": expected ${JSON.stringify(expected[key])} but got ${JSON.stringify(actual[key])}\n`));
		}
	}
};

const testIDs = () => {
	for (const [gameSlug, gameID] of Object.entries(canonicalSlugToGame)) {
		// eslint-disable-next-line no-console
		console.assert(parseGameID(gameSlug) === gameID, `parseGameID("${gameSlug}") should be "${gameID}"`);
		// eslint-disable-next-line no-console
		console.assert(parseGameID(gameID) === gameID, `parseGameID("${gameID}") should be "${gameID}"`);
		// eslint-disable-next-line no-console
		console.assert(gameNameToSlug(gameSlug) === gameSlug, `gameNameToSlug("${gameSlug}") should be "${gameSlug}"`);
	}
};

const stopTests = () => {
	testing = false;
	testsUI.hidden = true;
};

const runTests = async () => {
	testRouting();
	testIDs();

	testing = true;

	const realTime = location.hash.match(/realtime/);
	const wasMuted = muted;
	if (!realTime && !muted) {
		// don't want to save the muted state,
		// but we do want to update the UI, so don't just set muted = true
		toggleMute({ savePreference: false });
	}
	if (realTime && paused) {
		togglePause();
	}
	if (editing) {
		toggleEditing();
	}

	testsUI.hidden = false;

	const render = () => {
		const passedTests = tests.filter((test) => test.state === "passed");
		const failedTests = tests.filter((test) => test.state === "failed");

		testsInfo.innerHTML = `
			<p>Passed: ${passedTests.length} / ${tests.length}</p>
			<p>Failed: ${failedTests.length} / ${tests.length}</p>
		`;
		testsUL.innerHTML = "";
		for (const test of tests) {
			const li = document.createElement("li");
			const emoji = {
				"passed": "✅",
				"failed": "❌",
				"failed-to-load": "⚠️", // ⚠️🗺️❌🌐📶
				"pending": "🌙", // 🛏️😴💤🧍🔜
				"running": "🏃", // 🦿🤖🚧🔛
			}[test.state];
			li.innerHTML = `
			<h3>
				<span class="icon">${emoji}</span>
				<a href="#level=Test Cases;${levelNameToSlug(test.name)}">${test.name}</a>
			</h3>
			<div>${test.message || ""}</div>`; // || test.state
			testsUL.append(li);
		}
	};
	for (const test of tests) {
		test.state = "pending";
		test.message = "";
		test.timeSteps ??= 1000;
	}
	render();

	/* eslint-disable no-await-in-loop */
	for (const test of tests) {
		if (!testing) {
			break;
		}
		try {
			if (test.levelType === "json") {
				deserializeJSON(await loadTextFile(`levels/test-cases/${test.name}.json`));
				initLevel(currentLevel);
			} else {
				initLevel(await loadLevelFromTextFile(`levels/test-cases/${test.name}.txt`));
			}
		} catch (error) {
			test.state = "failed-to-load";
			test.message = `Failed to load test level: ${error}`;
			render();
			// eslint-disable-next-line no-continue
			continue;
		}
		editorLevelState = serializeToJSON(currentLevel);

		test.state = "running";
		render();

		paused = false;

		let won = false;
		let lost = false;
		// eslint-disable-next-line no-loop-func
		const checkTestEnd = () => {
			if (winOrLose() === "win") {
				won = true;
			}
			if (winOrLose() === "lose") {
				lost = true;
			}
			return won || lost || paused;
		};
		for (let timeStep = 0; timeStep < test.timeSteps; timeStep++) {
			// eslint-disable-next-line no-loop-func
			await new Promise((resolve) => {
				requestAnimationFrame(resolve); // accounts for one time step, with `++` above and `- 1` below (assuming animation loop is running)
				for (let i = 0; i < testSpeedInput.valueAsNumber - 1; i++) {
					simulate(entities);
					timeStep += 1;
					if (checkTestEnd()) {
						break;
					}
				}
			});
			checkTestEnd();
			if (paused) {
				if (editing) {
					stopTests();
					if (muted !== wasMuted) {
						toggleMute({ savePreference: false });
					}
					location.hash = `#tests/${levelNameToSlug(test.name)}/edit`;
					return;
				}
				paused = false;
				break;
			}
		}
		if (won && lost) {
			test.state = "failed";
			test.message = "Both won and lost (at different times) - this should never happen!";
		} else if (test.expect === "to win") {
			if (won) {
				test.state = "passed";
			} else {
				test.state = "failed";
				test.message = `Expected to win (in ${test.timeSteps} time steps)`;
				if (lost) {
					test.message += ", but lost instead";
				} else {
					test.message += ", but neither won nor lost";
				}
			}
		} else if (test.expect === "to lose") {
			if (lost) {
				test.state = "passed";
			} else {
				test.state = "failed";
				test.message = `Expected to lose (in ${test.timeSteps} time steps)`;
				if (won) {
					test.message += ", but won instead";
				} else {
					test.message += ", but neither won nor lost";
				}
			}
		} else if (test.expect === "to draw") {
			if (!lost && !won) {
				test.state = "passed";
			} else {
				test.state = "failed";
				test.message = `Expected to draw - neither win nor lose (in ${test.timeSteps} time steps)`;
				if (won) {
					test.message += ", but won instead";
				} else if (lost) {
					test.message += ", but lost instead";
				}
			}
		} else {
			test.state = "failed";
			test.message = `Unknown test type "${test.expect}"`;
		}
		render();
	}
	/* eslint-enable no-await-in-loop */

	if (muted !== wasMuted) {
		toggleMute({ savePreference: false });
	}
	setTimeout(() => {
		testing = false;
	});
};

// #endregion
//
// █████ █████ █   █ █████ ███ █   █ █████
// █   █ █   █ █   █   █    █  ██  █ █                 _____                ==o
// █████ █   █ █   █   █    █  █ █ █ █ ███          ___ |[]|_n__n_I_c       |              ==o
// █  █  █   █ █   █   █    █  █  ██ █   █         |___||__|###|____}       |  ,,mM########|##########################
// █  ██ █████ █████   █   ███ █   █ █████ #########O-O--O-O+++--O-O-\########WW###########|##MM######################
//                                                                                             `'+W###################
// #region Routing (load from URL hash)

// Routing plan:
//
// #/ (or anything not matched)
//   ╚══> redirect to title screen for Junkbot 1, or if making a sequel, perhaps Junkbot 3
// #/junkbot
//   ╚══> title screen
// #/junkbot/levels
//   ╚══> redirect to level select's first tab/page (building/basement)
// #/junkbot/levels/building-1
//   ╚══> level select, specific tab (building)
// #/junkbot/levels/building-1/new-employee-training
//   ╚══> first level
// #/junkbot/levels/building-1/new-employee-training/edit
//   ╚══> level editor for first level, redirects to local version when you make a change
// #/junkbot2
//   ╚══> title screen for Junkbot Undercover
// #/junkbot2/levels/basement-1/descent
//   ╚══> first level of the sequel
// #/tests
//   ╚══> run all tests
// #/tests/tippy-toast
//   ╚══> run a specific test (or at least load the level)
// #/level-editor
//   ╚══> new file
// #/local/foo-bar/edit
//   ╚══> existing file
// #/data/a99897sdf987a9879a9as70gah0986h96gjs6797659.../edit
//   ╚══> existing level (easy sharing)
//
// Old routes:
//
// #level=Junkbot%20Undercover;Water%20Works
//   ╚══> redirect to #junkbot2/levels/basement-1/water-works
// #level=local;water%20works
//   ╚══> redirect to #edit/local/water-works (if I'm gonna bother making it work to load like that, hyphens vs space)
// #level=Test%20Cases;Tippy%20Toast
//   ╚══> redirect to #tests/tippy-toast
// #run-tests
//   ╚══> redirect to #tests
//
// Notes:
// - I'm using "junkbot2" rather than "junkbot-uc" or similar, so that if I make a sequel, there's an easy working title, "junkbot3" ;)
// - I automatically canonicalize URLs (letter case, etc.) with replaceState
//   - I support some synonyms: "junkbot2"/"junkbot-uc"/"junkbot-undercover", "edit"/"editor"/"editing"/"edit-mode"/"ed"/"e"
//   - #edit/<level> is synonymous with #<level>/edit
//   - I could synonymize "_"/"-"/"" (I do in some cases, like for game names)
//   - I could allow the game to be omitted, so you can type e.g. "#descent" instead of "#junkbot2/levels/basement-1/descent".
// - Some unmatched routes show an error
// - Should I include the slash at the start? "#/foo/bar" vs "#foo/bar"
// - I might get rid of locally stored levels, in favor of data in the URL (like beepbox.co and some other web apps).

const parseRoute = (hash) => {
	hash = hash.replace(/^#?\/?/, "").replace(/\/$/, "");
	const hashParts = hash.split("/").map(decodeURIComponent);
	hash = decodeURIComponent(hash);
	const editSynonyms = ["edit", "editor", "level-editor", "editing", "editable", "edit-mode", "ed", "e", "design", "designer"];
	const levelSelectSynonyms = ["levels", "level-select", "level-selector", "select", "select-level", "choose-level"];
	const levelGroupRegexp = /^(basement(-area)?|building|section|page|tab|group|area|zone)-/i;
	let wantsEdit = false;
	let maybeLevelSelect = hashParts.some((hashPart) => levelSelectSynonyms.includes(hashPart));
	let game;
	let levelGroup;
	let levelName;
	for (let i = 0; i < hashParts.length; i++) {
		if (editSynonyms.includes(hashParts[i].toLowerCase()) && !wantsEdit) {
			wantsEdit = true;
		} else if (hashParts[i].match(/^(levels|tests)$/i)) {
			if (hashParts[i].match(/tests/i) && hashParts[i + 1]?.match(/levels/)) {
				i += 1;
			}
			if (hashParts[i + 1]?.match(levelGroupRegexp)) {
				levelGroup = hashParts[i + 1]; // (we should get it on the next loop iteration anyways...)
				levelName = hashParts[i + 2];
				i += 2;
			} else {
				levelName = hashParts[i + 1];
				i += 1;
			}
		} else if (hashParts[i].match(levelGroupRegexp)) {
			levelGroup = hashParts[i];
			levelName = hashParts[i + 1];
			maybeLevelSelect = true;
		}
	}
	// Parse old URLs (#level=game;level-name)
	const match = hash.match(/level=([^;&]+);([^;&]+)$/i);
	if (match) {
		game = parseGameID(match[1]);
		levelName = match[2];
	}

	for (const hashPart of hashParts) {
		if (parseGameID(hashPart)) {
			game = parseGameID(hashPart);
		}
	}
	if (hashParts[0].match(/level=Test Cases/)) {
		game = GAME_TEST_CASES;
	}
	game ??= GAME_JUNKBOT;

	let canonicalHash = `#${gameNameToSlug(game)}`;
	let screen = SCREEN_TITLE;
	const paginated = game !== GAME_USER_CREATED && game !== GAME_TEST_CASES;
	if (!paginated) {
		levelGroup = undefined;
	}
	let levelGroupSlug = levelGroup ? levelGroupToSlug(levelGroup, game) : undefined;

	if (levelName) {
		screen = SCREEN_LEVEL;
		if (game === GAME_TEST_CASES) {
			canonicalHash = `#tests/${levelNameToSlug(levelName)}`;
		} else if (levelGroupSlug) {
			canonicalHash = `#${gameNameToSlug(game)}/levels/${levelGroupSlug}/${levelNameToSlug(levelName)}`;
		} else {
			canonicalHash = `#${gameNameToSlug(game)}/levels/${levelNameToSlug(levelName)}`;
		}
		if (wantsEdit) {
			canonicalHash += "/edit";
		}
	} else if (game === GAME_TEST_CASES) {
		screen = SCREEN_LEVEL;
		canonicalHash = "#tests";
	} else if (wantsEdit) {
		screen = SCREEN_LEVEL;
		canonicalHash = "#level-editor";
		game = GAME_USER_CREATED;
	} else if (maybeLevelSelect) {
		screen = SCREEN_LEVEL_SELECT;
		if (levelGroupSlug) {
			canonicalHash = `#${gameNameToSlug(game)}/levels/${levelGroupSlug}`;
		} else if (paginated) {
			levelGroup = "1";
			levelGroupSlug = levelGroupToSlug(levelGroup, game);
			canonicalHash = `#${gameNameToSlug(game)}/levels/${levelGroupSlug}`;
		} else {
			canonicalHash = `#${gameNameToSlug(game)}/levels`;
		}
	}

	return {
		game,
		// levelName,
		levelSlug: levelName ? levelNameToSlug(levelName) : undefined,
		levelGroup: levelGroupSlug,
		screen,
		canonicalHash,
		wantsEdit,
	};
};

const loadFromHash = async () => {

	// Keep track of the location hash we're loading from, so that if the user navigates away, we can abort the load.
	// This is important to avoid race conditions, for robust routing.
	// To test the routing, it helps a lot to enable network throttling in the devtools. Then load screens and navigate while loading.

	// This fixes a race condition where it could hide the title screen UI,
	// and leave you with just the title screen level, navigating back to the title screen.

	// To test: Open the title screen, click Start, go back (Alt+Left),
	// then hold Alt and press Right and then Left quickly together,
	// almost as if they're one key, but in that order specifically.
	// Press Alt+Right/Left several times to make sure the title screen is always shown properly.

	// You can also try simply spamming Alt+Left/Right; note that in Chrome it aborts fetches, so it can show an error message to the user currently.
	let loadingFrom = location.hash;

	const { screen, levelSlug, levelGroup, game, wantsEdit, canonicalHash } = parseRoute(location.hash);

	// console.log(`${location.hash}\nvs\n${canonicalHash}`, parseRoute(location.hash));
	if (location.hash !== canonicalHash) {
		// replaceState does not trigger hashchange.
		// but triggering hashchange would cause infinite recursion without special care.
		// and we have promises to keep! so we actually want to load this time, not in a recursion (waiting for a recursive call would be ugly, if going through the event).
		history.replaceState(null, null, canonicalHash);
		loadingFrom = canonicalHash;
	}

	const toShowTestRunner = game === GAME_TEST_CASES && !levelSlug;
	if (!toShowTestRunner) {
		stopTests();
	}

	if (!infoBox.hidden) {
		// don't need to show it initially or at any routes right now so this is fine
		// this prevents it from showing on the title screen, colliding
		toggleInfoBox();
	}

	if (screen === SCREEN_LEVEL || screen === SCREEN_LEVEL_SELECT) {
		// These are routes that require all resources to be loaded (i.e. anything but the title screen)

		// Only load (and derive) resources once
		allResourcesLoadedPromise ??= loadResources(allResourcePaths).then(deriveHotResources);
		hotResourcesLoadedPromise ??= allResourcesLoadedPromise;
		resources = await allResourcesLoadedPromise;

		if (location.hash !== loadingFrom) {
			// prevents e.g. running tests if you load #run-tests part way and navigate elsewhere
			// (test this with network throttling in the devtools)
			return;
		}

		if (screen === SCREEN_LEVEL_SELECT) {
			hideTitleScreen();
			await closeNonErrorDialogs();
			showLevelSelectScreen(game, levelGroup);
			return; // don't want to hide the level select screen below
		}

		// These are routes that show a level screen
		if (toShowTestRunner) {
			runTests();
			closeNonErrorDialogs();
			hideTitleScreen();
			hideLevelSelectScreen();
		} else {
			if (levelSlug && game === GAME_USER_CREATED) {
				try {
					const json = localStorage[storageKeys.level(levelSlug)];
					if (!json) {
						throw new Error("Level does not exist.");
					}
					deserializeJSON(json);
					initLevel(currentLevel);
					dragging = entities.filter((entity) => entity.grabbed);
					editorLevelState = serializeToJSON(currentLevel);
				} catch (error) {
					showErrorMessage(`Failed to load local level for editing ("${levelSlug}")`, error);
					location.hash = "#junkbot/levels";
					return;
				}
			} else if (levelSlug) {
				try {
					try {
						const level = await loadLevelByName({ levelName: levelSlug, game });
						if (location.hash !== loadingFrom) {
							return;
						}
						initLevel(level);
						editorLevelState = serializeToJSON(currentLevel);
					} catch (error) {
						showErrorMessage(`Failed to load level "${levelSlug}"`, error);
						location.hash = "#junkbot/levels";
						return;
					}

					// For editor
					if (initializedEditorUI) {
						levelDropdown.selectedIndex = 0;
						levelDropdown.value = levelSlug; // names should be unique across games
						if (levelDropdown.selectedIndex <= 0) { // 0 = "Custom World", -1 = no items
							showErrorMessage(`Level "${levelSlug}" not found in dropdown.`);
						}
					}
				} catch (error) {
					showErrorMessage(`Failed to load level "${levelSlug}"`, error);
					location.hash = "#junkbot/levels";
					return;
				}
			} else {
				if (!wantsEdit || game !== GAME_USER_CREATED) {
					showErrorMessage("No level specified.");
					location.hash = "#junkbot/levels";
					return;
				}
				// Level editor with default level (#level-editor route)
				initLevel(resources.levelEditorDefaultLevel);
				editorLevelState = serializeToJSON(currentLevel);
			}

			// Hide other screen after loading the level so that there's not a flash of the title screen level without the title screen frame.
			paused = true;
			hideTitleScreen();
			hideLevelSelectScreen();
			await closeNonErrorDialogs();

			if (wantsEdit !== editing) {
				toggleEditing();
			}

			if (editing) {
				paused = true;
			} else {
				const levelLocation = whereLevelIsInTheGame(currentLevel, game);
				if (levelLocation) {
					// Show level name as a sort of toast
					const levelInfoContent = document.createElement("div");
					levelInfoContent.innerHTML = `
						<h1 class="level-info-header"><img class="level-info-building-image"><img class="level-info-building-text-image"></h1>
						<h2 class="level-info-title"></h2>
					`;
					const { pageNumber, levelNumber } = levelLocation;
					if (game === GAME_JUNKBOT) {
						levelInfoContent.querySelector(".level-info-building-image").src = `images/menus/building_icon_${pageNumber}.png`;
						levelInfoContent.querySelector(".level-info-building-text-image").src = `images/menus/building_text_${pageNumber}.png`;
					} else if (game === GAME_JUNKBOT_UNDERCOVER) {
						levelInfoContent.querySelector(".level-info-header").textContent = `Basement ${pageNumber}`;
					} else if (game === GAME_TEST_CASES) {
						levelInfoContent.querySelector(".level-info-header").textContent = "Test Cases";
					} else {
						levelInfoContent.querySelector(".level-info-header").remove();
					}
					levelInfoContent.querySelector(".level-info-title").textContent = `Level ${levelNumber}: ${currentLevel.title.toLocaleUpperCase()}`;

					const toast = showMessageBox([levelInfoContent], { buttons: [], className: "level-info-toast" });
					nonErrorDialogs.push(toast);
					// Don't await this delay, because we want the animation loop to start so the level gets rendered.
					setTimeout(async () => {
						await toast.close(true);
						// Unpause, unless user switched into edit mode by now
						paused = editing;
					}, 2500);
				} else {
					paused = false;
				}
			}
		}
	} else {
		hotResourcesLoadedPromise ??= loadResources(hotResourcePaths).then(deriveHotResources);
		resources = await hotResourcesLoadedPromise;
		if (location.hash !== loadingFrom) {
			return;
		}
		await closeNonErrorDialogs();
		showTitleScreen();
		hideLevelSelectScreen();

		// We loaded the title screen!
		// There's more to load, but we don't want to block showing the title screen level,
		// so kick off an asynchronous function without awaiting it.
		(async () => {
			allResourcesLoadedPromise ??= loadResources(otherResourcePaths).then((restOfResources) => {
				Object.assign(resources, restOfResources);
				return resources; // needs to return all resources so that it doesn't unload them when starting the game
			});
			resources = await allResourcesLoadedPromise;

			// Wait for "READY TO PLAY" text image to load before showing it to prevent flash of missing text.
			// I'm also delaying enabling the start game button because it feels weird to do those at different times.
			// I actually handle loading resources if you were to navigate to a different level while stuff is loading,
			// but I don't want to show the play button while the title screen is still loading,
			// because I want you to see the title screen level, and maybe interact with it, before starting the game.

			// Note that this strategy only works if cache is enabled; make sure "Disable cache" is unchecked in devtools.
			// Also if just this one image fails to load, I don't care, so using finally.
			loadImage("images/menus/ready_to_play.png").finally(() => {
				loadStatusLoaded.hidden = false;
				loadStatusLoading.hidden = true;

				startGameButton.hidden = false;
				showCreditsButton.hidden = false;
			});
		})();
	}
};

window.addEventListener("hashchange", loadFromHash);

// #endregion
// -------------------------.   _____------________                                                            ______      ..
// █   █ █████ █████ █████   \_/                   \                                                          / ᴹᴱᵀᴬ \____// \_/
// ██ ██ █       █   █   █    | █▖ ▗█ █▀▀ ▀█▀ █▀█  |.------------------.                      ______.-----__-'   /'''\ₘₑₜₐ/
// █ █ █ █████   █   █████    | █▜▅▛█ █▀▀  █  █▅█  |   ┏━┳━┓┏━╸━┳━┏━┓   \________________ .-''   _    META  meta/     ''''
// █   █ █       █   █   █    | █ ▀ █ █▅▅  █  █ █  |   ┃ ╹ ┃┣━╸ ┃ ┣━┫   |   |\/|[_~|~ /| '   /V\E|/\ /'''''--'''
// █   █ █████   █   █   █    | .....-------.....__|   ╹   ╹┗━╸ ╹ ╹ ╹   |   |  |[_ | /-|  _---------'
// ---------------------------'/                  \__/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾'-----------------./
// #region Meta

const loadAllLevels = (games = [GAME_JUNKBOT, GAME_JUNKBOT_UNDERCOVER]) => {
	const promises = [];
	for (const { game, levelNames } of getLevelLists(resources)) {
		if (games.includes(game)) {
			for (const levelName of levelNames) {
				promises.push(loadLevelByName({ game, levelName }));
			}
		}
	}
	return Promise.all(promises);
};
// eslint-disable-next-line no-unused-vars
const gatherStatistics = async (games) => {
	const occurrencesPerEntityType = {};
	const levelsPerEntityType = {};
	const levels = await loadAllLevels(games);
	for (const level of levels) {
		const recordedTypesInThisLevel = [];
		for (const entity of level.entities) {
			if (recordedTypesInThisLevel.indexOf(entity.type) === -1) {
				recordedTypesInThisLevel.push(entity.type);
				levelsPerEntityType[entity.type] = (levelsPerEntityType[entity.type] || 0) + 1;
			}
			occurrencesPerEntityType[entity.type] = (occurrencesPerEntityType[entity.type] || 0) + 1;
		}
	}
	return { levelsPerEntityType, occurrencesPerEntityType };
};
// eslint-disable-next-line no-unused-vars
const renderBannerComment = (sectionName) => {
	// This generates a code heading comment, using the game's pixel font.
	// You can use `console.log(renderBannerComment("Meta"))`
	// to generate a comment, but I use a VS Code extension "Banner Comments +" to do it quicker.
	// I made the font into a figlet font in order to be compatible; see below FIGlet font generation.
	const canvas = document.createElement("canvas");
	canvas.width = 80;
	canvas.height = fontCharHeight;
	const ctx = canvas.getContext("2d");
	drawText(ctx, sectionName, 0, 0, "white", "transparent", false);
	const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
	let textArt = "";
	for (let y = 0; y < canvas.height; y++) {
		textArt += "// ";
		for (let x = 0; x < canvas.width; x++) {
			textArt += imageData.data[(y * canvas.width + x) * 4 + 3] ? "█" : " ";
		}
		textArt += "\n";
	}
	return `
// #${""}endregion
//
${textArt.replace(/\s+$/gm, "")}
//
// #${""}region ${sectionName}
`;
};
// eslint-disable-next-line no-unused-vars
const renderFIGletFont = () => {
	// Generate a FIGlet font (.flf) from the Junkbot font.
	// This can be used with the "Banner Comments +" VS Code extension,
	// or other software.
	// A configuration is provided in the workspace to generate banner comments in this project's style.
	// It should work automatically if you install the extension:
	// https://marketplace.visualstudio.com/items?itemName=lunarlimbo.banner-comments-plus
	const canvas = document.createElement("canvas");
	const ctx = canvas.getContext("2d");
	const requiredCodes = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 196, 214, 220, 223, 228, 246, 252];
	const nonRequiredCodes = []; // not supported in this generator code
	// for (const char of fontChars) {
	// 	const code = char.charCodeAt(0);
	// 	if (requiredCodes.indexOf(code) === -1) {
	// 		console.log("Non-required code:", code, char);
	// 		nonRequiredCodes.push(code);
	// 	}
	// }
	const hardblank = "$";
	const fillChar = "█"; // chkfont gives "ERROR- Inconsistent character width", apparently considering bytes to equal spaces?
	const fillCharByteLength = (new TextEncoder().encode(fillChar)).length;
	// const fillChar = "#"; // if you want a font that passes chkfont
	// const fillCharByteLength = 1;
	const baseline = fontCharHeight;
	const maxCharWidth = Math.max(...fontCharW);
	const headroom = 5;
	const maxLineLength = (maxCharWidth + headroom) * fillCharByteLength + 2; // +2 for "@@"
	const oldLayout = 15; // http://www.jave.de/figlet/figfont.html#interpretlayout
	const comment = `04B_08 font as seen in Junkbot & Junkbot Undercover games, extended by Isaiah Odhner.
Some characters might be different from the original 04B_08 font since literally only the characters AS-SEEN in the games were copied exactly, before knowing of the original font.`;
	const commentLineCount = comment.split("\n").length;
	const printDirection = 0; // 0 = left to right, 1 = right to left
	const fullLayout = 143; // http://www.jave.de/figlet/figfont.html#interpretlayout
	const codetagCount = nonRequiredCodes.length;
	let flf = `flf2a${hardblank} ${fontCharHeight} ${baseline} ${maxLineLength} ${oldLayout} ${commentLineCount} ${printDirection} ${fullLayout} ${codetagCount}`;
	flf += `\n${comment}\n`;
	for (const code of requiredCodes) {
		let char = String.fromCharCode(code);
		let charIndex = fontCharToIndex[char];
		if (charIndex === -1 || charIndex === undefined) {
			char = char.toUpperCase();
			charIndex = fontCharToIndex[char];
		}
		// Note: "ß".toUpperCase() === "SS", and similarly
		// "ß".toLocaleUpperCase() === "SS".
		// Preferring matching letter-case isn't enough if it's defined in the font as uppercase ẞ.
		if (char === "SS") {
			char = "ẞ";
			charIndex = fontCharToIndex[char];
		}
		const charWidth = charIndex === -1 ? 0 : fontCharW[charIndex];
		canvas.width = charWidth;
		canvas.height = fontCharHeight;
		drawText(ctx, char, 0, 0, "white", "transparent", false);
		const imageData = charWidth && ctx.getImageData(0, 0, canvas.width, canvas.height);
		for (let y = 0; y < canvas.height; y++) {
			if (char === " ") {
				flf += hardblank;
				flf += hardblank;
				flf += hardblank;
			} else if (imageData) {
				flf += " ";
				for (let x = 0; x < canvas.width; x++) {
					flf += imageData.data[(y * canvas.width + x) * 4 + 3] ? fillChar : " ";
				}
				flf += hardblank;
			} else {
				// eslint-disable-next-line no-console
				console.warn("Missing char:", char, "(not necessarily a problem)");
			}
			flf += "@";
			if (y === baseline - 1) {
				flf += "@";
			}
			flf += "\n";
		}
	}
	flf += "\n";
	return flf;
};
// addEventListener("load", () => {
// 	hotResourcesLoadedPromise.then(() => {
// 		console.log(renderFIGletFont());
// 	});
// });

// #endregion
//                            ()   __    __   ()
// █   █ █████ ███ █   █      ||__/| |  | |\__||
// ██ ██ █   █  █  ██  █      || | @ |  | @ | ||
// █ █ █ █████  █  █ █ █      || @ | |  | | @ ||
// █   █ █   █  █  █  ██      || | @ |  | @ | ||
// █   █ █   █ ███ █   █      || @ | |  | | @ ||
//                            ||_|_|_|  |_|_|_||
// #region Main

const main = async () => {
	try {
		showDebug = localStorage[storageKeys.showDebug] === "true";
		muted = localStorage[storageKeys.muteSoundEffects] === "true";
		let volume = parseFloat(localStorage[storageKeys.volume]);
		if (!isFinite(volume) || volume < 0 || volume > 1) {
			volume = 0.5;
		}
		mainGain.gain.value = volume;
	} catch (error) {
		// eslint-disable-next-line no-console
		console.error("Couldn't initialize preferences:", error);
	}

	initGUI();

	winLoseState = winOrLose(); // prevent pausing in checkLevelEnd before level is loaded

	await loadFromHash();

	animate();
};

main();

// #endregion
//     _______________
//    |____________|__|
//   //_________//    |
//  //_________//     |_____\/_ _  _            |                _\/_
// //_________//      |____/o\\__           \       /            //o\
// | _________ |      |____ | __              .---.                |
// | \__/^\__/ |______|_____|_______     --  /     \  --     ______|__
// |           |      |____ _ ___   `~^~^~^~^~^~^~^~^~^~^~^~`
// |    ____/  | ,>-v |____
// |           | ^-<' |  _______   ___                    ____
// |___________|______| /      | _________     .  .       / /
//    /       /\      |/ ( ) /||   ____     .`_._'_..    / /
//   /       //\\      \    /_|/            \   o   /   / /
//   |      ||  \\         /                 \ /   /  _/ /_
//   |      ||   -\_______/            `. ~. `\___/'./~.' /.~'`.
//   |      ||          .    '   .  `, .`'`.`.'`'`.~.`'~.`'`.~`  '
//  /_______//     ,'.    ,  .,    .     .       .  . . ,   ;   '  ,
//  |______|/ . ' ,
