MediaWiki:Pikcross.js: Difference between revisions

From the Super Mario Wiki, the Mario encyclopedia
Jump to navigationJump to search
mNo edit summary
mNo edit summary
 
(One intermediate revision by the same user not shown)
Line 2,688: Line 2,688:
  */
  */
function onPageTouchEnd(event) {
function onPageTouchEnd(event) {
event.preventDefault();
if (event.target === canvasEl) {
event.preventDefault();
}
onPageInputUp();
onPageInputUp();
}
}

Latest revision as of 13:47, December 23, 2023

// PLEASE do not modify or tamper with this script for your
// own Javascript-based Picross needs without permission!
// Message Espyo on pikminwiki.com instead before doing so.
// If you're here in the name of archival or just personalbar
// amusement in running this locally, however, go nuts.
// Just put in a good word to them, will ya? Most of the actual
// coding work was theirs, after all.

// Canvas constants.
const CANVAS = {
	// Width.
	WIDTH: 800,
	// Height.
	HEIGHT: 800,
};

// Length of a border gradient effect.
const BORDER_GRADIENT_LENGTH = 20;
// Width and height of each cell, in pixels.
const CELL_SIZE = 32;
// Padding between each cell.
const CELL_PADDING = 1;
// Maximum number of rows or columns a board can have.
const MAX_COLS_OR_ROWS = 40;
// Minimum number of rows or columns a board can have.
const MIN_COLS_OR_ROWS = 2;
// Padding between sections of the game screen.
const SECTION_PADDING = 10;
// Transition total duration.
const TRANSITION_DURATION = 0.4;

// Color for the game's background.
const COLOR_BG = 'rgb(17, 51, 51)';
// Color for the board's background.
const COLOR_BOARD_BG = 'rgba(34, 68, 68, 0.6)';
// Color for the hints' background.
const COLOR_HINTS_BG = 'rgb(51, 85, 85)';
// Color for the footer's background.
const COLOR_FOOTER_BG = 'rgb(68, 102, 102)';
// Colors for the hint text.
const COLOR_HINT = ['#262', '#383'];
// Color for the hint text when defied.
const COLOR_HINT_DEFIED = '#E11';
// Color to tint the hint button with when defied.
const COLOR_HINT_DEFIED_TINT = 'rgba(255, 0, 0, 0.7)';
// Color for a button's background.
const COLOR_BUTTON_BG = '#BCC';
// Colors for a button's shadow.
const COLOR_BUTTON_SHADOW = ['rgba(0, 64, 64, 0.5)', 'rgba(64, 64, 0, 0.5)'];
// Colors for buttons' text.
const COLOR_BUTTON_TEXT = ['#262', '#383'];
// Color for buttons' text while disabled.
const COLOR_BUTTON_TEXT_DISABLED = '#888';
// Color for a blank cell.
const COLOR_BLANK = '#EFF';
// Color for a filled cell.
const COLOR_FILLED = '#699';
// Color for a marked cell.
const COLOR_MARKED = '#DEE';
// Color for a maker tool that's green.
const COLOR_MAKER_GREEN = '#262';
// Color for a maker tool that's red.
const COLOR_MAKER_RED = '#622';
// Colors for generic text.
const COLOR_TEXT_GENERIC = ['#CEC', '#BEB'];

// Value for a blank cell.
const CELL_BLANK = 0;
// Value for a filled cell.
const CELL_FILLED = 1;
// Value for a marked cell.
const CELL_MARKED = 2;

// Main menu.
const STATE_MAIN_MENU = 0;
// Playing a puzzle.
const STATE_PLAYING = 1;
// Making a puzzle.
const STATE_MAKING = 2;

// In-game pause menu.
const PANEL_STATE_PAUSE = 0;
// Congratulating the player on a finished puzzle.
const PANEL_STATE_CONGRATS = 1;
// Asking for the new puzzle's name.
const PANEL_STATE_MAKING_NAME = 2;
// Showing the new puzzle's code.
const PANEL_STATE_MAKING_CODE = 3;
// Asking for a custom puzzle code to play on.
const PANEL_STATE_PLAYING_CODE = 4;
// Warning the player of an error while loading the level.
const PANEL_STATE_LOAD_ERROR = 5;
// Warning the player of an error while saving the level.
const PANEL_STATE_SAVE_ERROR = 6;
// Warning the player they have a bookmark when they try to start a level.
const PANEL_STATE_BOOKMARK_WARNING = 7;
// Info.
const PANEL_STATE_INFO = 8;

// When encoding or decoding data, do it for a level.
const BOARD_DATA_CONTEXT_LEVEL = 0;
// When encoding or decoding data, do it for a bookmark.
const BOARD_DATA_CONTEXT_BOOKMARK = 1;

// Button.
const GUI_ITEM_BUTTON = 0;
// Text.
const GUI_ITEM_TEXT = 1;

// Spritesheet, in the context of loading things.
const LOAD_CONTENT_SPRITES = 0;
// Game setup, in the context of loading things.
const LOAD_CONTENT_SETUP = 1;


// All levels.
// Yes, the level editor actually was... THE LEVEL EDITOR FOR THE ORIGINAL APPLET! Dun dun DUNNN!~
const LEVELS = [
	// Mushroom
	'BQXuf0IACE11c2hyb29t',
	// Barrel
	'BQVu5uoABkJhcnJlbA==',
	// Mario
	'Cgr4+P+/y6FF5iPB+HMOBU1hcmlv',
	// Yoshi Egg
	'CgowIMEMMxLpJJthhOABCVlvc2hpIEVnZw==',
	// King Totomesu
	'Dw/+HwGQ/48XqPpXIXz0AwiBf1+A4J8QkP//MjgPAA1LaW5nIFRvdG9tZXN1',
	// Power Jump Badge
	'Dw/gPxggDBAGCP7DBTFBTZATxAXhQdj/ZzRisWAwABBQb3dlciBKdW1wIEJhZGdl',
	// Gold Ghost
	'Dw8AACAA+AFeAK/Av+GA/eh/4n6Bf8A7AA4AAAAAAApHb2xkIEdob3N0',
	// Beanstar
	'Dw/AAbAB2ADH4dWbKk+QnXKMGAYMjgMpgZzAe8AYAAhCZWFuc3Rhcg==',
	// Propeller Mushroom
	'Dw+egLBfX9D/B2NAQBBAHHDuO/ubj48pg5SAIIAPABJQcm9wZWxsZXIgTXVzaHJvb20=',
	// Mini Mario
	'Dw/AARgDggJBQf9jU5BC+B86B9mH8oR5AtOASMA/AApNaW5pIE1hcmlv',
	// Weird Mushroom
	'Dw/gARwDH0KGI4DhfwADgAHAAGAAMAAsABYADQADAA5XZWlyZCBNdXNocm9vbQ==',
	// Pi'illo
	'Dw8AAOecjMl9Z0GroNZfm7P5T+GQIEjXZRwDAAAAAAdQaSdpbGxv',
	// Cappy
	'Dw8APAAJ+AeSBLGFJII2QZuYJJodMjj+DwFSdNFFAAVDYXBweQ==',
	// "Hachisuke"
	'Dw+D4Lijqord84inv/z48R/feQQY/g/9hXmKgY7/AQlIYWNoaXN1a2U=',
	// Poochy
	'Dw8AHsw8WV+kLcQm/GP4w8DID/An/Av9rD+Fz4FTAAZQb29jaHk=',
	// ~Cool Secret Obscure Reference!~
	'Dw8AQP6/+C/4l/ypfkQfnAPAAe6Ac8AZIAwoB+QBAAM/Pz8=',

	// START OF B-SIDE PUZZLES
	// Issue #201!
	'ChT/A/YBU4BJpKREH0qkkPSKMPA9+Ar8D/7/C0lzc3VlICMyMDEh',
	// Donkey Kong
	'DxSAPwD8B2DOYEN4GRuJExk4gDHgwDGAmfyn6W4qR1yEKoIDGMB/AAtEb25rZXkgS29uZw==',
	// Bowser
	'FBT/7/5/5P8z/udwPwTxmIg/cfARCDGBIAoQQAYBmBABEFMAeWdy/J8xP4zw8CT/J/N/AgZCb3dzZXI=',
	// Kamek
	'FBSAPwAGPPwAbDCAAoyvHf8m4kdiPD7qIxzvAeCYAIQBQBg8giIkyAfBwB8M/uCjM88T/AVLYW1law==',
	// Mario's Picross (picrossception?!)
	'FBQAH4ANBjSgwAA+xBkkYoCS//n3fEuNUiQVJtrzoUUfGnjBgCcCWOZDnPxHDNsCgSs4xg9NYXJpbydzIFBpY3Jvc3M=',
	// Tumble
	'FBQADgAQA4BNANQY4DICLyNwxwL5LfA4AVcfMOUBAw5wl4FpJ/g5gmYkkIQBQQgQhAC+BwZUdW1ibGU=',
	// Shine Sprite
	'FBQADgCgAAAOwEFwFArFo3jQcQEKCmDAAJIIGNJDsEE4yQMKCmDVAKoK0H8B4xgACgBAAAxTaGluZSBTcHJpdGU=',
	// Wario's Adventure
	'FBQAAADwDsC9APMOCMRBnDP0x/LzNhE6k6G/cjwqDWKEP4b4P5BUAUk1EP8CAiDA/wEAABFXYXJpbydzIEFkdmVudHVyZQ==',
	// Funky Kong
	'FBSAHwD0AuB5APoFMIaAARi0/0INL2hggQEQMICAAxj4/4FVGbiCgf8XcIAAHgTAPwDwAApGdW5reSBLb25n',
	// Princess Peach
	'FBRABQCqAGAdAG8CjMBAAAgGwCHnEEyUgGYJaOYAAQ4YMIOYIBCMAc8TSDKBGhJcIUEmCw5QcmluY2VzcyBQZWFjaA==',
	// Miiverse
	'FBQAAADwAIAZAAwDeOnBBjZGKSZoQYIQZAxjRCBiBGKjUFwKpcc2Tm0r1LbCbz+AHwAAAAhNaWl2ZXJzZQ==',
	// Biddybud (since you're here; we've had a tab of this image for months because the idea of a Dedicated Biddybud Tab was funny to us. this is the chekov's gun of our year.)
	'FBQAAAAAAMA/AAMMCABBgCMEeCKAR/pxZD5AQgYkQWAS5WfSf3n+Of+Pvl/IA0QAgAMAAAhCaWRkeWJ1ZA==',
	// Talking Flower
	'FBSAGQB0A7DZgJgRhBkiAECCHyT8Q8I/JPxDBA+CABAwwOD8c7LQZAxjiRkZkIAGBoafHw5UYWxraW5nIEZsb3dlcg==',
	// Hotel Mario
	'FBQA7gHYIUAGBCRAIG0H83kIA8LIcUKSJGdZbI6HAGQMB+rw4B7+b8J9Q0gC+B/hQHAHCAtIb3RlbCBNYXJpbw==',
	// Shroom Spotlight
	'FBSAAAYIUOCDAjL0IL/pM70n4TsM3wPk1q80qZqRqhOpejGoF/56AfgXAPADAC4AQAEACBBTaHJvb20gU3BvdGxpZ2h0',
	// The 3 Koppaites.
	'FBQAAAD4AMAaAFQBwBgAVAHAGgD4AIAIAAQBQBDgAz5jUBUFY/kfFAVjY1DlAz4AAAAAAAM/Pz8=',
];


// Sprite data in the spritesheet.
const SPRITES = {
	// Blank cell.
	CELL_BLANK: {
		x: 0, y: 0, width: 32, height: 32,
	},
	// Filled cell.
	CELL_FILLED: {
		x: 32, y: 0, width: 32, height: 32,
	},
	// Marked cell.
	CELL_MARKED: {
		x: 64, y: 0, width: 32, height: 32,
	},
	// Logo.
	LOGO: {
		x: 97, y: 0, width: 199, height: 46,
	},
	// Hocotate background.
	HOCOTATE: {
		x: 0, y: 47, width: 800, height: 800,
	},
	// Koppai background.
	KOPPAI: {
		x: 800, y: 47, width: 800, height: 800,
	},
};


// Screen section coordinate information.
const sectionCoords = {
	// Main menu header.
	mainMenuHeader: {
		x: 0,
		y: 0,
		width: CANVAS.WIDTH,
		height: CANVAS.HEIGHT * 0.2,
	},
	// Main menu level selection.
	levelSelect: {
		x: 0,
		y: CANVAS.HEIGHT * 0.2,
		width: CANVAS.WIDTH,
		height: CANVAS.HEIGHT * 0.8,
	},
	// Board coordinates.
	board: {
		x: 0,
		y: 0,
		width: 0,
		height: 0,
	},
	// Miniature coordinates.
	miniature: {
		x: 0,
		y: 0,
		width: 0,
		height: 0,
	},
	// Row hints coordinates.
	rowBanner: {
		x: 0,
		y: 0,
		width: 0,
		height: 0,
	},
	// Column hints coordinates.
	colBanner: {
		x: 0,
		y: 0,
		width: 0,
		height: 0,
	},
	// Footer coordinates.
	footer: {
		x: 0,
		y: 0,
		width: 0,
		height: 0,
	},
	// Panel.
	panel: {
		x: CANVAS.WIDTH * 0.2,
		y: CANVAS.HEIGHT * 0.2,
		width: CANVAS.WIDTH * 0.6,
		height: CANVAS.HEIGHT * 0.6,
	},
};
//Coordinates for GUI items.
const guiItemCoords = {
	// Title, in the main menu.
	mainMenuTitle: {
		x: 0.3,
		y: 0.2,
		width: 0.4,
		height: 0.4,
	},
	// Continue button in the main menu.
	mainMenuContinue: {
		x: 0.025,
		y: 0.12,
		width: 0.20,
		height: 0.30,
	},
	// Info button in the main menu.
	mainMenuInfo: {
		x: 0.025,
		y: 0.58,
		width: 0.20,
		height: 0.30,
	},
	// Make custom button in the main menu.
	mainMenuMake: {
		x: 0.775,
		y: 0.12,
		width: 0.20,
		height: 0.30,
	},
	// Play custom button in the main menu.
	mainMenuCustom: {
		x: 0.775,
		y: 0.58,
		width: 0.20,
		height: 0.30,
	},
	// Swap side button in the main menu.
	mainMenuSide: {
		x: 0.40,
		y: 0.65,
		width: 0.20,
		height: 0.22,
	},
	// Pause button.
	pause: {
		x: SECTION_PADDING,
		y: SECTION_PADDING,
		width: 45,
		y2: -SECTION_PADDING,
	},
	// Title, in the playing state.
	playingTitle: {
		x: 0.425,
		y: 0.10,
		width: 0.15,
		height: 0.40,
	},
	// Level number, in the playing state.
	playingLevel: {
		x: 0.00,
		y: 0.50,
		width: 1.00,
		height: 0.40,
	},
	// Zoom in button.
	zoomIn: {
		x: -110,
		y: SECTION_PADDING,
		width: 45,
		y2: -SECTION_PADDING,
	},
	// Zoom out button.
	zoomOut: {
		x: -55,
		y: SECTION_PADDING,
		width: 45,
		y2: -SECTION_PADDING,
	},
	// Pause panel continue button.
	continue: {
		x: 0.2,
		y: 0.1,
		width: 0.6,
		height: 0.15,
	},
	// Pause panel restart button.
	restart: {
		x: 0.2,
		y: 0.3,
		width: 0.6,
		height: 0.15,
	},
	// Pause panel finish button.
	finish: {
		x: 0.2,
		y: 0.5,
		width: 0.6,
		height: 0.15,
	},
	// Pause panel quit button.
	quit: {
		x: 0.2,
		y: 0.75,
		width: 0.6,
		height: 0.15,
	},
	// General panel ok button.
	ok: {
		x: 0.2,
		y: 0.75,
		width: 0.6,
		height: 0.15,
	},
	// Congrats panel header text.
	congratsHeader: {
		x: 0.2,
		y: 0.05,
		width: 0.6,
		height: 0.15,
	},
	// Congrats panel puzzle miniature.
	congratsMiniature: {
		x: 0.2,
		y: 0.20,
		width: 0.6,
		height: 0.40,
	},
	// Congrats panel level name text.
	congratsLevelName: {
		x: 0.2,
		y: 0.60,
		width: 0.6,
		height: 0.15,
	},
	// New puzzle panel name prompt.
	newPuzzleNamePrompt: {
		x: 0.0,
		y: 0.2,
		width: 1.0,
		height: 0.1,
	},
	// New puzzle panel done text.
	newPuzzleDoneText: {
		x: 0.0,
		y: 0.2,
		width: 1.0,
		height: 0.1,
	},
	// Custom puzzle panel explanation text.
	customPuzzleText: {
		x: 0.0,
		y: 0.2,
		width: 1.0,
		height: 0.1,
	},
	// Load error explanation 1 text.
	loadError1: {
		x: 0.0,
		y: 0.2,
		width: 1.0,
		height: 0.1,
	},
	// Load error explanation 2 text.
	loadError2: {
		x: 0.0,
		y: 0.4,
		width: 1.0,
		height: 0.1,
	},
	// Load error explanation 3 text.
	loadError3: {
		x: 0.0,
		y: 0.5,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning explanation 1 text.
	bookmarkWarning1: {
		x: 0.0,
		y: 0.15,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning explanation 2 text.
	bookmarkWarning2: {
		x: 0.0,
		y: 0.20,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning explanation 3 text.
	bookmarkWarning3: {
		x: 0.0,
		y: 0.25,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning explanation 4 text.
	bookmarkWarning4: {
		x: 0.0,
		y: 0.30,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning explanation 5 text.
	bookmarkWarning5: {
		x: 0.0,
		y: 0.35,
		width: 1.0,
		height: 0.1,
	},
	// Bookmark warning go back button.
	bookmarkWarningBack: {
		x: 0.2,
		y: 0.55,
		width: 0.6,
		height: 0.15,
	},
	// Bookmark warning play level button.
	bookmarkWarningStart: {
		x: 0.2,
		y: 0.75,
		width: 0.6,
		height: 0.15,
	},
	// Info 1 text.
	info1: {
		x: 0.0,
		y: 0.15,
		width: 1.0,
		height: 0.1,
	},
	// Info 2 text.
	info2: {
		x: 0.0,
		y: 0.30,
		width: 1.0,
		height: 0.1,
	},
	// Info 3 text.
	info3: {
		x: 0.0,
		y: 0.35,
		width: 1.0,
		height: 0.1,
	},
	// Info 4 text.
	info4: {
		x: 0.0,
		y: 0.40,
		width: 1.0,
		height: 0.1,
	},
	// Info 5 text.
	info5: {
		x: 0.0,
		y: 0.45,
		width: 1.0,
		height: 0.1,
	},
};


// Camera information.
let cam = {
	// Current coordinates. Used for panning.
	coords: {x: 0, y: 0},
	// Current zoom level.
	zoom: 1.0,
};
// Player input information. Is controlled by both the mouse and mobile touches.
let input = {
	// Coordinates in the game world.
	worldCoords: {x: 0, y: 0},
	// Coordinates on-screen, with 0,0 being the top-left of the canvas.
	screenCoords: {x: 0, y: 0},
	// Is the player currently dragging?
	dragging: false,
	// X/Y coordinates of where the dragging started.
	dragStart: {x: 0, y: 0},
	// Lock either coordinate when dragging.
	dragLockCoord: {x: false, y: false},
	// What is the player currently doing to the cells? -1 means nothing.
	dragAction: -1,
	// Is the player dragging to pan the view?
	dragPanning: false,
};
// Board information.
let board = {
	// Number of rows. Cache for convenience.
	nrRows: 0,
	// Number of columns. Cache for convenience.
	nrCols: 0,
	// State of every cell.
	cells: [],
	// Solution.
	solution: [],
	// Hints for each row.
	rowHints: [],
	// Hints for each column.
	colHints: [],
	// Whether a given row is auto-marked.
	autoMarkedRows: [],
	// Whether a given column is auto-marked.
	autoMarkedCols: [],
	// Whether a given row is defied.
	defiedRows: [],
	// Whether a given column is defied.
	defiedCols: [],
	// Puzzle name.
	name: '',
	// Level number. 0 means custom.
	levelNumber: 0,
	// Level code.
	levelCode: '',
};
// Array of true/false, representing whether a given level's been cleared.
let progression = [];
// Array with data about all levels.
let levelsData = [];
// Canvas object.
let canvas;
// Canvas HTML element.
let canvasEl;
// Input box HTML element.
let inputEl;
// Spritesheet image.
let sprites;
// What things have been loaded. If any of these are false, the game's not ready. Use the LOAD_CONTENTS_ constants.
let loaded = [false, false];
// Game state.
let state = STATE_MAIN_MENU;
// Are we currently showing the panel?
let inPanel = false;
// State of the panel.
let panelState = PANEL_STATE_PAUSE;
// Gradients to show on the borders of the board, when parts of the board are off-camera.
let borderGradients = {
	left: null,
	right: null,
	up: null,
	down: null,
};
// Have we warned the player of their pending bookmark yet?
let gaveBookmarkWarning = false;
// Current game side. 0 for Hocotate, 1 for Koppai.
let gameSide = 0;
// Scene transition time left.
let transitionAnimTime = 0;
// Transition phase.
let transitionPhase = 0;
// State to change to after the transition.
let transitionNewState = 0;
// Transition code to run after the state change. undefined for none.
let transitionCodeAfter = undefined;


/**
 * Code to run when the player presses Ok when the panel shows the input box.
 */
function acceptInputBox() {
	let input = inputEl.value;
	input = input.replace(/[^\x00-\x7F]/g, '');

	switch (panelState) {
		case PANEL_STATE_MAKING_NAME:

			if (inputEl.value.length === 0) return;

			if (input.length === 0) input = 'Puzzle';
			board.name = input;
			const boardData = {
				nrRows: board.nrRows,
				nrCols: board.nrCols,
				cells: board.cells,
				name: board.name,
			};

			inputEl.value = encodeBoard(boardData, BOARD_DATA_CONTEXT_LEVEL);
			panelState = PANEL_STATE_MAKING_CODE;
			showInputBox(true);

			break;

		case PANEL_STATE_MAKING_CODE:

			hideInputBox();
			inPanel = false;

			break;

		case PANEL_STATE_PLAYING_CODE:

			hideInputBox();

			if (inputEl.value.length === 0) {
				inPanel = false;
			} else {
				if (loadLevel(0, input, STATE_PLAYING)) {
					changeState(STATE_PLAYING, function () {
						inPanel = false;
					});
					clearBookmark();
				} else {
					panelState = PANEL_STATE_LOAD_ERROR;
				}
			}

			break;

	}

	updateCanvas();
}


/**
 * Checks if all hints in a row/column have been filled by the player.
 * @param {array} hints Array of hints to check.
 */
function allHintsAreFilled(hints) {
	for (let h = 0; h < hints.length; h++) {
		if (hints[h].state !== CELL_FILLED) return false;
	}
	return true;
}


/**
 * Given an X or Y coordinate of a GUI item inside some parent coordinates, this returns
 * what the final on-screen coordinates are, depending on how the GUI item's coordinates are formatted.
 * This function can take either X coordinates and widths, or it can take Y coordinates and heights.
 * 1 to infinity means the item is offset these many pixels from the parent's start.
 * 0 to 1 means the item is within this ratio of parent size, starting from the parent's start.
 * -1 to 0 means the item is within this ratio of parent size, starting from the parent's end.
 * -Infinity to -1 means the item is offset these many pixels from the parent's end.
 * @param {number} coord Coordinate to calculate.
 * @param {number} parentStart Start coordinate of the parent.
 * @param {number} parentSize Size of the parent.
 * @returns The calculated final coordinate.
 */
function calculateChildCoord(coord, parentStart, parentSize) {
	let parentEnd = parentStart + parentSize;
	if (coord > 1.00) {
		// Pixel offset from start.
		return parentStart + coord;
	} else if (coord >= 0.00) {
		// Ratio of size from start.
		return parentStart + parentSize * coord;
	} else if (coord > -1.00) {
		// Ratio of size from end.
		return parentEnd - parentSize * -coord;
	} else {
		// Pixel offset from end.
		return parentEnd - -coord;
	}
}


/**
 * Returns the final X and Y coordinates of a child object, making use of calculateChildCoord.
 * @param {object} coords Object with the child coordinates.
 * @param {object} parentCoords Object with the parent coordinates.
 */
function calculateChildCoords(coords, parentCoords) {
	return {
		x: calculateChildCoord(coords.x, parentCoords.x, parentCoords.width),
		y: calculateChildCoord(coords.y, parentCoords.y, parentCoords.height),
	};
}


/**
 * Given a width or height of a GUI item inside some parent coordinates, this returns
 * what the final on-screen size is, depending on how the GUI item's width/height is formatted.
 * This function can take either X coordinates and widths, or it can take Y coordinates and heights.
 * 1 to infinity means the item's size is these many pixels.
 * 0 to 1 means the item is this ratio of parent size.
 * @param {number} coord Coordinate to calculate.
 * @param {number} parentSize Size of the parent.
 * @returns The calculated final size.
 */
function calculateChildDimension(coord, parentSize) {
	if (coord > 1.00) {
		// Pixel size.
		return coord;
	} else if (coord > 0.00) {
		// Ratio size.
		return parentSize * coord;
	}
}


/**
 * Returns the final X and Y dimensions of a child object, making use of calculateChildDimension.
 * @param {object} coords Object with the child coordinates.
 * @param {object} finalXY The object's final X and Y coordinates, calculated elsewhere.
 * @param {object} parentCoords Object with the parent coordinates.
 */
function calculateChildDimensions(coords, finalXY, parentCoords) {
	let finalCoords = {};
	if (coords.x2 === undefined) {
		finalCoords.width = calculateChildDimension(coords.width, parentCoords.width);
	} else {
		let x2 = calculateChildCoord(coords.x2, parentCoords.x, parentCoords.width);
		finalCoords.width = x2 - finalXY.x;
	}
	if (coords.y2 === undefined) {
		finalCoords.height = calculateChildDimension(coords.height, parentCoords.height);
	} else {
		let y2 = calculateChildCoord(coords.y2, parentCoords.y, parentCoords.height);
		finalCoords.height = y2 - finalXY.y;
	}
	return finalCoords;
}


/**
 * Changes the state of a cell, and updates everything accordingly.
 * @param {number} row Cell row number.
 * @param {number} col Cell column number.
 * @param {number} newState The cell's new state.
 * @returns True if the cell changed, false otherwise.
 */
function changeCell(row, col, newState) {
	if (board.cells[row][col] === newState) {
		return false;
	}

	board.cells[row][col] = newState;

	if (state === STATE_PLAYING) {
		updateRow(row);
		updateColumn(col);

		let solved = true;
		for (let r = 0; r < board.nrRows; r++) {
			for (let c = 0; c < board.nrCols; c++) {
				let cellState = board.cells[r][c];
				if (cellState === CELL_MARKED) cellState = CELL_BLANK;
				if (cellState !== board.solution[r][c]) {
					solved = false;
					break;
				}
			}
			if (!solved) break;
		}

		if (solved) {
			if (board.levelNumber !== 0) {
				progression[board.levelNumber - 1] = true;
				saveProgression();
			}
			clearBookmark();
			inPanel = true;
			panelState = PANEL_STATE_CONGRATS;
		} else {
			saveBookmark();
		}
	}

	return true;
}


/**
 * Changes the current game state with a transition.
 * @param {number} newState New state.
 * @param {function} codeAfter Code to run after the transition.
 */
function changeState(newState, codeAfter = undefined) {
	transitionNewState = newState;
	transitionAnimTime = TRANSITION_DURATION;
	transitionPhase = 0;
	transitionCodeAfter = codeAfter;
}


/**
 * Clamps the camera coordinates to make sure they don't go too far to the left, right, up, or down.
 * Also snaps to 0,0 if it's close enough.
 */
function clampCamera() {
	cam.coords.x = Math.min(cam.coords.x, (board.nrCols - 1) * (CELL_SIZE + CELL_PADDING));
	cam.coords.x = Math.max(cam.coords.x, -(sectionCoords.board.width - CELL_SIZE));
	cam.coords.y = Math.min(cam.coords.y, (board.nrRows - 1) * (CELL_SIZE + CELL_PADDING));
	cam.coords.y = Math.max(cam.coords.y, -(sectionCoords.board.height - CELL_SIZE));

	cam.zoom = Math.max(cam.zoom, 0.1);
	cam.zoom = Math.min(cam.zoom, 5);
}


/**
 * Debug function that automatically marks all levels as cleared.
 * This does not save the player's progression.
 */
function clearAllLevels() {
	for (let l = 0; l < progression.length; l++) {
		progression[l] = true;
	}
	updateCanvas();
}


/**
 * Clears the player's bookmark.
 */
function clearBookmark() {
	localStorage.setItem('pikcrossBookmark', '');
}


/**
 * Decodes a base64-encoded string into a board.
 * @param {string} data Encoded board text.
 * @param {number} context BOARD_DATA_CONTEXT_LEVEL to decode level data. BOARD_DATA_CONTEXT_BOOKMARK to decode bookmark data.
 * @returns Board data. Returns null on error.
 */
function decodeBoard(data, context) {
	let result = {
		nrRows: 0,
		nrCols: 0,
		cells: [],
		name: '',
		nrRowHints: 0,
		rowHints: [],
		nrColHints: 0,
		colHints: [],
		levelNumber: 0,
		levelCode: '',
	};
	let nrCellBits = context === BOARD_DATA_CONTEXT_LEVEL ? 1 : 2;

	try {

		let reader = new StrBitReader(atob(data));

		result.nrRows = reader.readNumber();
		result.nrCols = reader.readNumber();
		for (let r = 0; r < result.nrRows; r++) {
			result.cells.push([]);
			for (let c = 0; c < result.nrCols; c++) {
				result.cells[r].push(reader.readNumber(nrCellBits));
			}
		}

		if (context === BOARD_DATA_CONTEXT_LEVEL) {

			let nrNameChars = reader.readNumber();
			for (let c = 0; c < nrNameChars; c++) {
				result.name += String.fromCharCode(reader.readNumber());
			}

		} else {

			result.nrRowHints = reader.readNumber();
			result.nrColHints = reader.readNumber();
			for (let r = 0; r < result.nrRowHints; r++) {
				result.rowHints.push(reader.readNumber(1));
			}
			for (let c = 0; c < result.nrColHints; c++) {
				result.colHints.push(reader.readNumber(1));
			}

			result.levelNumber = reader.readNumber();

			let nrCodeChars = reader.readNumber();
			for (let c = 0; c < nrCodeChars; c++) {
				result.levelCode += String.fromCharCode(reader.readNumber());
			}

		}

		return result;

	} catch {
		return null;
	}
}


/**
 * Draws the board game screen.
 */
function drawBoard() {
	const boardX2 = sectionCoords.board.x + sectionCoords.board.width;
	const boardY2 = sectionCoords.board.y + sectionCoords.board.height;

	// Board, one cell at a time.
	canvas.save();
	canvas.beginPath();
	canvas.rect(sectionCoords.board.x, sectionCoords.board.y, sectionCoords.board.width, sectionCoords.board.height);
	canvas.clip();

	drawSprite(gameSide === 0 ? SPRITES.HOCOTATE : SPRITES.KOPPAI, sectionCoords.board.x, sectionCoords.board.y, sectionCoords.board.width, sectionCoords.board.height);
	canvas.fillStyle = COLOR_BOARD_BG;
	canvas.fillRect(sectionCoords.board.x, sectionCoords.board.y, sectionCoords.board.width, sectionCoords.board.height);
	for (let r = 0; r < board.nrRows; r++) {
		for (let c = 0; c < board.nrCols; c++) {
			let startX = sectionCoords.board.x + c * CELL_SIZE + c * CELL_PADDING - cam.coords.x;
			let startY = sectionCoords.board.y + r * CELL_SIZE + r * CELL_PADDING - cam.coords.y;
			let sprite = null;
			if (board.cells[r][c] === CELL_BLANK) {
				if (board.autoMarkedRows[r] || board.autoMarkedCols[c]) {
					sprite = SPRITES.CELL_MARKED;
				} else {
					sprite = SPRITES.CELL_BLANK;
				}
			} else if (board.cells[r][c] === CELL_FILLED) {
				sprite = SPRITES.CELL_FILLED;
			} else {
				sprite = SPRITES.CELL_MARKED;
			}
			drawSprite(sprite, startX, startY, CELL_SIZE, CELL_SIZE);
		}
	}

	// Board fives grid.
	canvas.lineWidth = 3;
	for (let r = 5; r < board.nrRows; r += 5) {
		let y = sectionCoords.board.y + r * CELL_SIZE + r * CELL_PADDING - cam.coords.y;
		let minX = sectionCoords.board.x - cam.coords.x;
		let maxX = sectionCoords.board.x + board.nrCols * CELL_SIZE + board.nrCols * CELL_PADDING - cam.coords.x;
		canvas.beginPath();
		canvas.moveTo(minX, y);
		canvas.lineTo(maxX, y);
		canvas.stroke();
	}
	for (let c = 5; c < board.nrCols; c += 5) {
		let x = sectionCoords.board.x + c * CELL_SIZE + c * CELL_PADDING - cam.coords.x;
		let minY = sectionCoords.board.y - cam.coords.y;
		let maxY = sectionCoords.board.y + board.nrRows * CELL_SIZE + board.nrRows * CELL_PADDING - cam.coords.y;
		canvas.beginPath();
		canvas.moveTo(x, minY);
		canvas.lineTo(x, maxY);
		canvas.stroke();
	}
	canvas.font = 'bold 12px sans';
	canvas.fillStyle = '#888';
	canvas.textAlign = 'right';
	canvas.textBaseline = 'bottom';
	for (let r = 5; r < board.nrRows; r += 5) {
		let y = sectionCoords.board.y + r * CELL_SIZE + r * CELL_PADDING - cam.coords.y;
		let maxX = sectionCoords.board.x + board.nrCols * CELL_SIZE + board.nrCols * CELL_PADDING - cam.coords.x;
		maxX = Math.min(maxX, boardX2);
		canvas.fillText(r, maxX - 1, y);
	}
	canvas.textAlign = 'right';
	canvas.textBaseline = 'bottom';
	for (let c = 5; c < board.nrCols; c += 5) {
		let x = sectionCoords.board.x + c * CELL_SIZE + c * CELL_PADDING - cam.coords.x;
		let maxY = sectionCoords.board.y + board.nrRows * CELL_SIZE + board.nrRows * CELL_PADDING - cam.coords.y;
		maxY = Math.min(maxY, boardY2);
		canvas.fillText(c, x - 1, maxY);
	}

	// Board border gradients.
	if (cam.coords.x > 0) {
		canvas.fillStyle = borderGradients.left;
		canvas.fillRect(sectionCoords.board.x, sectionCoords.board.y - cam.coords.y, BORDER_GRADIENT_LENGTH, board.nrRows * (CELL_SIZE + CELL_PADDING));
	}
	if (cam.coords.x < -(sectionCoords.board.width - board.nrCols * (CELL_SIZE + CELL_PADDING))) {
		canvas.fillStyle = borderGradients.right;
		canvas.fillRect(boardX2 - BORDER_GRADIENT_LENGTH, sectionCoords.board.y - cam.coords.y, boardX2, board.nrRows * (CELL_SIZE + CELL_PADDING));
	}
	if (cam.coords.y > 0) {
		canvas.fillStyle = borderGradients.up;
		canvas.fillRect(sectionCoords.board.x - cam.coords.x, sectionCoords.board.y, board.nrCols * (CELL_SIZE + CELL_PADDING), BORDER_GRADIENT_LENGTH);
	}
	if (cam.coords.y < -(sectionCoords.board.height - board.nrRows * (CELL_SIZE + CELL_PADDING))) {
		canvas.fillStyle = borderGradients.down;
		canvas.fillRect(sectionCoords.board.x - cam.coords.x, boardY2 - BORDER_GRADIENT_LENGTH, board.nrCols * (CELL_SIZE + CELL_PADDING), boardY2);
	}

	canvas.restore();

	// Miniature.
	canvas.fillStyle = COLOR_BOARD_BG;
	canvas.fillRect(sectionCoords.miniature.x, sectionCoords.miniature.y, sectionCoords.miniature.width, sectionCoords.miniature.height);
	drawMiniature(board.nrRows, board.nrCols, board.cells, {
		x: 0,
		y: 0,
		width: 1,
		height: 1,
	}, sectionCoords.miniature, COLOR_TEXT_GENERIC[gameSide]);

	if (state === STATE_PLAYING) {

		// Hints.
		canvas.font = 'bold 24px sans';

		// Row hints.
		let cellWidth = CELL_SIZE;
		let cellHeight = CELL_SIZE;

		canvas.save();
		canvas.beginPath();
		canvas.rect(sectionCoords.rowBanner.x, sectionCoords.rowBanner.y, sectionCoords.rowBanner.width, sectionCoords.rowBanner.height);
		canvas.clip();

		canvas.fillStyle = COLOR_HINTS_BG;
		canvas.fillRect(sectionCoords.rowBanner.x, sectionCoords.rowBanner.y, sectionCoords.rowBanner.width, sectionCoords.rowBanner.height);

		for (let r = 0; r < board.nrRows; r++) {
			for (let h = 0; h < board.rowHints[r].length; h++) {
				let xIndexOffset = board.rowHints[r].length - h - 1;
				let startX = sectionCoords.rowBanner.x + sectionCoords.rowBanner.width - cellWidth;
				startX -= xIndexOffset * cellWidth + xIndexOffset * CELL_PADDING;
				let startY = sectionCoords.rowBanner.y + r * cellWidth + r * CELL_PADDING - cam.coords.y;
				let sprite = null;
				if (board.rowHints[r][h].state === CELL_BLANK) {
					sprite = SPRITES.CELL_BLANK;
				} else {
					sprite = SPRITES.CELL_FILLED;
				}
				drawSprite(sprite, startX, startY, cellWidth, cellHeight);
				if (board.defiedRows[r]) {
					canvas.lineWidth = 5;
					canvas.strokeStyle = COLOR_HINT_DEFIED_TINT;
					canvas.strokeRect(startX, startY, cellWidth, cellHeight);
					canvas.fillStyle = COLOR_HINT_DEFIED;
				} else {
					canvas.fillStyle = COLOR_HINT[gameSide];
				}
				canvas.fillText(board.rowHints[r][h].nr, startX + cellWidth / 2, startY + cellHeight / 2 + 2);
			}
		}

		canvas.restore();

		// Column hints.
		cellWidth = CELL_SIZE;
		cellHeight = CELL_SIZE;

		canvas.save();
		canvas.beginPath();
		canvas.rect(sectionCoords.colBanner.x, sectionCoords.colBanner.y, sectionCoords.colBanner.width, sectionCoords.colBanner.height);
		canvas.clip();

		canvas.fillStyle = COLOR_HINTS_BG;
		canvas.fillRect(sectionCoords.colBanner.x, sectionCoords.colBanner.y, sectionCoords.colBanner.width, sectionCoords.colBanner.height);

		for (let c = 0; c < board.nrCols; c++) {
			for (let h = 0; h < board.colHints[c].length; h++) {
				let yIndexOffset = board.colHints[c].length - h - 1;
				let startX = sectionCoords.colBanner.x + c * cellWidth + c * CELL_PADDING - cam.coords.x;
				let startY = sectionCoords.colBanner.y + sectionCoords.colBanner.height - cellHeight;
				startY -= yIndexOffset * cellHeight + yIndexOffset * CELL_PADDING;
				let sprite = null;
				if (board.colHints[c][h].state === CELL_BLANK) {
					sprite = SPRITES.CELL_BLANK;
				} else {
					sprite = SPRITES.CELL_FILLED;
				}
				drawSprite(sprite, startX, startY, cellWidth, cellHeight);
				if (board.defiedCols[c]) {
					canvas.lineWidth = 5;
					canvas.strokeStyle = COLOR_HINT_DEFIED_TINT;
					canvas.strokeRect(startX, startY, cellWidth, cellHeight);
					canvas.fillStyle = COLOR_HINT_DEFIED;
				} else {
					canvas.fillStyle = COLOR_HINT[gameSide];
				}
				canvas.fillText(board.colHints[c][h].nr, startX + cellWidth / 2, startY + cellHeight / 2 + 2);
			}
		}

		canvas.restore();

	} else if (state === STATE_MAKING) {

		// Maker grid tools.
		canvas.font = 'bold ' + (CELL_SIZE - 2) + 'px sans';
		canvas.textAlign = 'center';
		canvas.textBaseline = 'middle';

		// Row hints.
		canvas.save();
		canvas.beginPath();
		canvas.rect(sectionCoords.rowBanner.x, sectionCoords.rowBanner.y, sectionCoords.rowBanner.width, sectionCoords.rowBanner.height);
		canvas.clip();

		canvas.fillStyle = COLOR_HINTS_BG;
		canvas.fillRect(sectionCoords.rowBanner.x, sectionCoords.rowBanner.y, sectionCoords.rowBanner.width, sectionCoords.rowBanner.height);

		for (let r = 0; r < board.nrRows; r++) {
			for (let b = 0; b < 3; b++) {
				let xIndexOffset = 3 - b - 1;
				let startX = sectionCoords.rowBanner.x + sectionCoords.rowBanner.width - CELL_SIZE;
				startX -= xIndexOffset * CELL_SIZE + xIndexOffset * CELL_PADDING;
				let startY = sectionCoords.rowBanner.y + r * CELL_SIZE + r * CELL_PADDING - cam.coords.y;
				let sprite = SPRITES.CELL_BLANK;
				drawSprite(sprite, startX, startY, CELL_SIZE, CELL_SIZE);
				let text = '';
				switch (b) {
					case 0:
						canvas.fillStyle = COLOR_MAKER_RED;
						text = 'x';
						break;
					case 1:
						canvas.fillStyle = COLOR_MAKER_GREEN;
						text = '↑';
						break;
					case 2:
						canvas.fillStyle = COLOR_MAKER_GREEN;
						text = '↓';
						break;
				}
				canvas.fillText(text, startX + CELL_SIZE / 2, startY + CELL_SIZE / 2 + 2);
			}
		}

		canvas.restore();

		// Column hints.
		canvas.save();
		canvas.beginPath();
		canvas.rect(sectionCoords.colBanner.x, sectionCoords.colBanner.y, sectionCoords.colBanner.width, sectionCoords.colBanner.height);
		canvas.clip();

		canvas.fillStyle = COLOR_HINTS_BG;
		canvas.fillRect(sectionCoords.colBanner.x, sectionCoords.colBanner.y, sectionCoords.colBanner.width, sectionCoords.colBanner.height);

		for (let c = 0; c < board.nrCols; c++) {
			for (let b = 0; b < 3; b++) {
				let yIndexOffset = 3 - b - 1;
				let startX = sectionCoords.colBanner.x + c * CELL_SIZE + c * CELL_PADDING - cam.coords.x;
				let startY = sectionCoords.colBanner.y + sectionCoords.colBanner.height - CELL_SIZE;
				startY -= yIndexOffset * CELL_SIZE + yIndexOffset * CELL_PADDING;
				let sprite = SPRITES.CELL_BLANK;
				drawSprite(sprite, startX, startY, CELL_SIZE, CELL_SIZE);
				let text = '';
				switch (b) {
					case 0:
						canvas.fillStyle = COLOR_MAKER_RED;
						text = 'x';
						break;
					case 1:
						canvas.fillStyle = COLOR_MAKER_GREEN;
						text = '←';
						break;
					case 2:
						canvas.fillStyle = COLOR_MAKER_GREEN;
						text = '→';
						break;
				}
				canvas.fillText(text, startX + CELL_SIZE / 2, startY + CELL_SIZE / 2 + 2);
			}
		}

		canvas.restore();

	}

	// Footer.
	canvas.fillStyle = COLOR_FOOTER_BG;
	canvas.fillRect(sectionCoords.footer.x, sectionCoords.footer.y, sectionCoords.footer.width, sectionCoords.footer.height);

	let logoXY = calculateChildCoords(guiItemCoords.playingTitle, sectionCoords.footer);
	let logoWH = calculateChildDimensions(guiItemCoords.playingTitle, logoXY, sectionCoords.footer);
	let levelNumberStr = board.levelNumber === 0 ? 'Custom Level' : 'Level ' + board.levelNumber;

	drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.pause, sectionCoords.footer, '...', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
	drawSprite(SPRITES.LOGO, logoXY.x, logoXY.y, logoWH.width, logoWH.height);
	drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.playingLevel, sectionCoords.footer, levelNumberStr, COLOR_TEXT_GENERIC[gameSide], 'bold 20px sans');
}


/**
 * Draws a generic GUI item in specific coordinates, like a button, some text, an image, etc.
 * @param {number} type Type of GUI item.
 * @param {object} coords Coordinates of the object, relative to the parent.
 * @param {object} parentCoords Coordinates of the parent section.
 * @param {string} fg Contents of the foreground, if any.
 * @param {string} fgStyle Foreground style, if any.
 * @param {string} fgFont Foreground content font, if any.
 * @param {string} bgStyle Background style. If undefined, the background won't be drawn.
 */
function drawGuiItem(type, coords, parentCoords, fg = undefined, fgStyle = undefined, fgFont = undefined, bgStyle = undefined) {
	if (parentCoords === undefined) {
		parentCoords = {
			x: 0,
			y: 0,
			width: CANVAS.WIDTH,
			height: CANVAS.HEIGHT,
		};
	}

	let finalXY = calculateChildCoords(coords, parentCoords);
	let finalWH = calculateChildDimensions(coords, finalXY, parentCoords);
	let finalCoords = {
		x: finalXY.x,
		y: finalXY.y,
		width: finalWH.width,
		height: finalWH.height,
	};

	// Button shadow.
	if (type === GUI_ITEM_BUTTON && bgStyle !== undefined) {
		canvas.fillStyle = COLOR_BUTTON_SHADOW[gameSide];
		canvas.fillRect(finalCoords.x + 4, finalCoords.y + 4, finalCoords.width, finalCoords.height);
	}

	// Background.
	if (bgStyle !== undefined) {
		canvas.fillStyle = bgStyle;
		canvas.fillRect(finalCoords.x, finalCoords.y, finalCoords.width, finalCoords.height);
	}

	// Foreground.
	if (fg !== undefined) {
		if (fgStyle !== undefined) {
			canvas.fillStyle = fgStyle;
		}
		if (fgFont !== undefined) {
			canvas.font = fgFont;
		}
		canvas.fillText(fg, finalCoords.x + finalCoords.width / 2, finalCoords.y + finalCoords.height / 2 + 2);
	}
}


/**
 * Draws the main menu.
 */
function drawMainMenu() {
	// Header.
	canvas.fillStyle = COLOR_FOOTER_BG;
	canvas.fillRect(sectionCoords.mainMenuHeader.x + SECTION_PADDING, sectionCoords.mainMenuHeader.y + SECTION_PADDING, sectionCoords.mainMenuHeader.width - SECTION_PADDING * 2, sectionCoords.mainMenuHeader.height - SECTION_PADDING * 2);

	let bookmarkData = getBookmarkData();
	let logoXY = calculateChildCoords(guiItemCoords.mainMenuTitle, sectionCoords.mainMenuHeader);
	let logoWH = calculateChildDimensions(guiItemCoords.mainMenuTitle, logoXY, sectionCoords.mainMenuHeader);
	drawSprite(SPRITES.LOGO, logoXY.x, logoXY.y, logoWH.width, logoWH.height);
	drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.mainMenuContinue, sectionCoords.mainMenuHeader, 'Continue', bookmarkData != null ? COLOR_BUTTON_TEXT[gameSide] : COLOR_BUTTON_TEXT_DISABLED, '20px sans', COLOR_BUTTON_BG);
	drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.mainMenuInfo, sectionCoords.mainMenuHeader, 'About', COLOR_BUTTON_TEXT[gameSide], '20px sans', COLOR_BUTTON_BG);
	drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.mainMenuMake, sectionCoords.mainMenuHeader, 'Make custom', COLOR_BUTTON_TEXT[gameSide], '20px sans', COLOR_BUTTON_BG);
	drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.mainMenuCustom, sectionCoords.mainMenuHeader, 'Play custom', COLOR_BUTTON_TEXT[gameSide], '20px sans', COLOR_BUTTON_BG);
	if (firstSideCleared()) drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.mainMenuSide, sectionCoords.mainMenuHeader, 'Swap sides', COLOR_BUTTON_TEXT[gameSide], '20px sans', COLOR_BUTTON_BG);

	// Level selection.
	drawSprite(gameSide === 0 ? SPRITES.HOCOTATE : SPRITES.KOPPAI, sectionCoords.levelSelect.x + SECTION_PADDING, sectionCoords.levelSelect.y + SECTION_PADDING, sectionCoords.levelSelect.width - SECTION_PADDING * 2, sectionCoords.levelSelect.height - SECTION_PADDING * 2);
	canvas.fillStyle = COLOR_BOARD_BG;
	canvas.fillRect(sectionCoords.levelSelect.x + SECTION_PADDING, sectionCoords.levelSelect.y + SECTION_PADDING, sectionCoords.levelSelect.width - SECTION_PADDING * 2, sectionCoords.levelSelect.height - SECTION_PADDING * 2);

	let buttonPadding = SECTION_PADDING * 2;
	let buttonWidth = (sectionCoords.levelSelect.width - buttonPadding * 5) / 4;
	let levelHeight = (sectionCoords.levelSelect.height - buttonPadding * 5) / 4;
	let buttonHeight = levelHeight * 0.75;

	for (let c = 0; c < 4; c++) {
		for (let r = 0; r < 4; r++) {
			let levelNumber = r * 4 + c + 1;
			levelNumber += 16 * gameSide;
			let cleared = progression[levelNumber - 1];
			drawGuiItem(
				GUI_ITEM_BUTTON,
				{
					x: buttonPadding + c * (buttonWidth + buttonPadding),
					y: buttonPadding + r * (levelHeight + buttonPadding),
					width: buttonWidth,
					height: buttonHeight,
				},
				sectionCoords.levelSelect,
				'',
				COLOR_BUTTON_TEXT[gameSide],
				'bold 48px sans',
				COLOR_BUTTON_BG,
			);
			if (cleared) {
				drawMiniature(
					levelsData[levelNumber - 1].nrRows,
					levelsData[levelNumber - 1].nrCols,
					levelsData[levelNumber - 1].cells,
					{
						x: buttonPadding + c * (buttonWidth + buttonPadding) + 8,
						y: buttonPadding + r * (levelHeight + buttonPadding) + 8,
						width: buttonWidth - 16,
						height: buttonHeight - 16,
					},
					sectionCoords.levelSelect,
					COLOR_BUTTON_TEXT[gameSide],
				);
				canvas.textAlign = 'left';
				canvas.textBaseline = 'top';
				drawGuiItem(
					GUI_ITEM_TEXT,
					{
						x: buttonPadding + c * (buttonWidth + buttonPadding),
						y: buttonPadding + r * (levelHeight + buttonPadding),
						width: 10,
						height: 5,
					},
					sectionCoords.levelSelect,
					levelsData[levelNumber - 1].nrRows + 'x' + levelsData[levelNumber - 1].nrCols,
					COLOR_BUTTON_SHADOW[gameSide],
					'9px sans',
				);
			}
			let levelName = levelNumber + ': ' + (cleared ? levelsData[levelNumber - 1].name : '???');
			canvas.textAlign = 'center';
			canvas.textBaseline = 'middle';
			drawGuiItem(
				GUI_ITEM_TEXT,
				{
					x: buttonPadding + c * (buttonWidth + buttonPadding),
					y: buttonPadding + r * (levelHeight + buttonPadding) + buttonHeight + 2,
					width: buttonWidth,
					height: levelHeight - buttonHeight,
				},
				sectionCoords.levelSelect,
				levelName,
				COLOR_TEXT_GENERIC[gameSide],
				'bold 14px sans',
			);
		}
	}
}


/**
 * Draws a miniature of the puzzle on-screen.
 * @param {number} nrRows Number of rows to draw.
 * @param {number} nrCols Number of columns to draw.
 * @param {array} cells Cells to draw.
 * @param {object} coords X, Y, width, and height of the miniature.
 * @param {object} parentCoords Object with the parent coordinates.
 * @param {string} color Color of each pixel.
 */
function drawMiniature(nrRows, nrCols, cells, coords, parentCoords, color) {
	if (parentCoords === undefined) {
		parentCoords = {
			x: 0,
			y: 0,
			width: CANVAS.WIDTH,
			height: CANVAS.HEIGHT,
		};
	}

	let finalXY = calculateChildCoords(coords, parentCoords);
	let finalWH = calculateChildDimensions(coords, finalXY, parentCoords);
	let finalCoords = {
		x: finalXY.x,
		y: finalXY.y,
		width: finalWH.width,
		height: finalWH.height,
	};

	let miniatureCellNormalWidth = finalCoords.width / nrCols;
	let miniatureCellNormalHeight = finalCoords.height / nrRows;
	let miniatureCellSize = Math.min(miniatureCellNormalWidth, miniatureCellNormalHeight);
	let miniatureFullWidth = miniatureCellSize * nrCols;
	let miniatureFullHeight = miniatureCellSize * nrRows;
	let miniatureStartX = finalCoords.x + (finalCoords.width - miniatureFullWidth) / 2;
	let miniatureStartY = finalCoords.y + (finalCoords.height - miniatureFullHeight) / 2;
	for (let r = 0; r < nrRows; r++) {
		for (let c = 0; c < nrCols; c++) {
			if (cells[r][c] !== CELL_FILLED) {
				continue;
			}
			let startX = Math.round(miniatureStartX + c * miniatureCellSize);
			let startY = Math.round(miniatureStartY + r * miniatureCellSize);

			canvas.fillStyle = color;
			canvas.fillRect(
				startX,
				startY,
				Math.ceil(miniatureCellSize),
				Math.ceil(miniatureCellSize),
			);
		}
	}
}


/**
 * Draws the panel.
 */
function drawPanel() {
	// Background.
	canvas.fillStyle = 'rgba(0, 0, 0, 0.5)';
	canvas.fillRect(0, 0, CANVAS.WIDTH, CANVAS.HEIGHT);

	canvas.fillStyle = 'rgba(0, 0, 0, 0.5)';
	canvas.fillRect(sectionCoords.panel.x + 8, sectionCoords.panel.y + 8, sectionCoords.panel.width, sectionCoords.panel.height);

	canvas.fillStyle = COLOR_FOOTER_BG;
	canvas.fillRect(sectionCoords.panel.x, sectionCoords.panel.y, sectionCoords.panel.width, sectionCoords.panel.height);

	switch (panelState) {
		case PANEL_STATE_PAUSE:
			// Menu buttons.
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.continue, sectionCoords.panel, 'Continue', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.restart, sectionCoords.panel, 'Restart', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			if (state === STATE_MAKING) {
				drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.finish, sectionCoords.panel, 'Finish', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			}
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.quit, sectionCoords.panel, 'Quit', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_CONGRATS:
			// Congrats screen.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.congratsHeader, sectionCoords.panel, (board.levelNumber === 0 ? 'CUSTOM LEVEL' : 'LEVEL ' + board.levelNumber) + ' CLEAR!', COLOR_TEXT_GENERIC[gameSide], '36px sans');
			drawMiniature(board.nrRows, board.nrCols, board.cells, guiItemCoords.congratsMiniature, sectionCoords.panel, COLOR_TEXT_GENERIC[gameSide]);
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.congratsLevelName, sectionCoords.panel, board.name, COLOR_TEXT_GENERIC[gameSide], 'italic 24px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_MAKING_NAME:
			// Asking the new puzzle's name.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.newPuzzleNamePrompt, sectionCoords.panel, 'What\'s this puzzle\'s name?', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_MAKING_CODE:
			// Showing the new puzzle's code.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.newPuzzleDoneText, sectionCoords.panel, 'Done! Copy this code and share it around!', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_PLAYING_CODE:
			// Asking a puzzle's code to play on.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.customPuzzleText, sectionCoords.panel, 'Please paste the puzzle\'s code here.', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_LOAD_ERROR:
			// Warning the player there was an error while loading.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError1, sectionCoords.panel, 'Error loading level!', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError2, sectionCoords.panel, 'Please make sure everything', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError3, sectionCoords.panel, 'is correct and try again.', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_SAVE_ERROR:
			// Warning the player there was an error while saving.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError1, sectionCoords.panel, 'Error saving level!', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError2, sectionCoords.panel, 'You must have at least', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.loadError3, sectionCoords.panel, 'one filled cell!', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_BOOKMARK_WARNING:
			// Warning the player they have a bookmark when starting a level.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.bookmarkWarning1, sectionCoords.panel, 'If you start a new puzzle, you\'ll', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.bookmarkWarning2, sectionCoords.panel, 'lose your old bookmark progress! In', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.bookmarkWarning3, sectionCoords.panel, 'the main menu, press "Continue" to', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.bookmarkWarning4, sectionCoords.panel, 'resume your bookmark, or press the', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.bookmarkWarning5, sectionCoords.panel, 'puzzle button again to start anyway.', COLOR_BUTTON_BG, '20px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Ok', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

		case PANEL_STATE_INFO:
			// Game info.
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.info1, sectionCoords.panel, 'SHROOM PICROSS CREDITS', COLOR_BUTTON_BG, 'bold 20px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.info2, sectionCoords.panel, 'Original Game Script - Espyo for Pikipedia', COLOR_BUTTON_BG, '18px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.info3, sectionCoords.panel, 'Modification & Puzzles - Camwoodstock & Tori', COLOR_BUTTON_BG, '18px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.info4, sectionCoords.panel, 'Additional GFX - Cookie Clicker, Gabumon', COLOR_BUTTON_BG, '18px sans');
			drawGuiItem(GUI_ITEM_TEXT, guiItemCoords.info5, sectionCoords.panel, 'Special Thanks - TPG, VG Resource, "Gramma K."', COLOR_BUTTON_BG, '18px sans');
			drawGuiItem(GUI_ITEM_BUTTON, guiItemCoords.ok, sectionCoords.panel, 'Thanks for play! ♥', COLOR_BUTTON_TEXT[gameSide], '24px sans', COLOR_BUTTON_BG);
			break;

	}

}


/**
 * Draws a sprite.
 * @param {object} data Data about the sprite in the spritesheet, in the form {x, y, width, height}.
 * @param {number} x Top-left X coordinate to draw on.
 * @param {number} y Top-left Y coordinate to draw on.
 * @param {number} w Width to draw at.
 * @param {number} h Height to draw at.
 */
function drawSprite(data, x, y, w, h) {
	canvas.drawImage(sprites, data.x, data.y, data.width, data.height, x, y, w, h);
}


/**
 * Encodes a board and some more data into a base64-encoded string.
 * @param {object} data Board data to encode.
 * @param {number} context BOARD_DATA_CONTEXT_LEVEL to encode level data. BOARD_DATA_CONTEXT_BOOKMARK to encode bookmark data.
 * @returns Encoded board text. Returns null on error.
 */
function encodeBoard(data, context) {
	try {
		let writer = new StrBitWriter();
		let nrCellBits = context === BOARD_DATA_CONTEXT_LEVEL ? 1 : 2;

		writer.writeNumber(data.nrRows);
		writer.writeNumber(data.nrCols);
		for (let r = 0; r < data.nrRows; r++) {
			for (let c = 0; c < data.nrCols; c++) {
				writer.writeNumber(data.cells[r][c], nrCellBits);
			}
		}

		if (context === BOARD_DATA_CONTEXT_LEVEL) {

			writer.writeNumber(data.name.length);
			for (let c = 0; c < data.name.length; c++) {
				writer.writeNumber(data.name.charCodeAt(c));
			}

		} else {

			writer.writeNumber(data.nrRowHints);
			writer.writeNumber(data.nrColHints);
			for (let r = 0; r < data.nrRowHints; r++) {
				writer.writeNumber(data.rowHints[r], 1);
			}
			for (let c = 0; c < data.nrColHints; c++) {
				writer.writeNumber(data.colHints[c], 1);
			}

			writer.writeNumber(data.levelNumber);

			writer.writeNumber(data.levelCode.length);
			for (let c = 0; c < data.levelCode.length; c++) {
				writer.writeNumber(data.levelCode.charCodeAt(c));
			}

		}

		return btoa(writer.getStr());

	} catch {
		return null;
	}
}


/**
 * Given a list of cells, finds the first cell that is marked. It only checks inside of a given range.
 * @param {array} cells List of cells to check in.
 * @param {number} startIdx Index to start the check in, inclusive.
 * @param {number} length Number of cells to check.
 * @returns The index of the first marked cell, or -1 if none are marked.
 */
function findMarkedCellInList(cells, startIdx, length) {
	for (let c = startIdx; c < startIdx + length; c++) {
		if (c >= cells.length) return c;
		if (cells[c] === CELL_MARKED) return c;
	}
	return -1;
}


/**
 * Returns whether the first side (i.e. the first 16 puzzles) is cleared or not.
 * @returns True if cleared, false otherwise.
 */
function firstSideCleared() {
	for (let l = 0; l < 16; l++) {
		if (!progression[l]) return false;
	}
	return true;
}


/**
 * Returns which cell of the board is under the given screen coordinates.
 * @param {object} coords Object with the coordinates.
 * @returns The row and column, in an array.
 */
function getCellInCoords(coords) {
	let x = coords.x - sectionCoords.board.x + cam.coords.x;
	let y = coords.y - sectionCoords.board.y + cam.coords.y;
	let col = Math.floor(x / (CELL_SIZE + CELL_PADDING));
	let row = Math.floor(y / (CELL_SIZE + CELL_PADDING));
	return [row, col];
}


/**
 * Returns which column hint/maker button is under the given screen coordinates.
 * @param {object} coords Object with the coordinates.
 * @returns The column and button index numbers, in an array. Returns null if none.
 */
function getColumnBannerButtonInCoords(coords) {
	let hintsClickCoords = {
		x: coords.x - sectionCoords.colBanner.x + cam.coords.x,
		y: coords.y - sectionCoords.colBanner.y,
	};
	let col = Math.floor(hintsClickCoords.x / (CELL_SIZE + CELL_PADDING));

	if (col < 0 || col >= board.nrCols) return null;

	let button = Math.floor(hintsClickCoords.y / (CELL_SIZE + CELL_PADDING));
	let maxButtons = Math.floor(sectionCoords.colBanner.height / (CELL_SIZE + CELL_PADDING));
	let nrButtonsInCol = 0;
	if (state === STATE_PLAYING) {
		nrButtonsInCol = board.colHints[col].length;
	} else if (state === STATE_MAKING) {
		nrButtonsInCol = 3;
	}
	button -= maxButtons - nrButtonsInCol;

	if (button < 0) return null;
	if (state === STATE_PLAYING) {
		if (button >= board.colHints[col].length) return null;
	} else if (state === STATE_MAKING) {
		if (button >= 3) return null;
	}

	return [col, button];
}


/**
 * Returns an array with the combos the player has for the given line.
 * @param {number|null} row Row number, if we're checking a row. null otherwise.
 * @param {number|null} col Column number, if we're checking a column. null otherwise.
 * @returns An array where each object is a combo.
 */
function getPlayerLineCombos(row, col) {
	let playerCombos = [];
	let curCombo = 0;
	let curComboStart = 0;

	if (row !== null) {
		for (let c = 0; c < board.nrCols; c++) {
			if (board.cells[row][c] === CELL_FILLED) {
				if (curCombo === 0) {
					curComboStart = c;
				}
				curCombo++;
			} else {
				if (curCombo > 0) playerCombos.push({start: curComboStart, nr: curCombo});
				curCombo = 0;
			}
		}
	} else {
		for (let r = 0; r < board.nrRows; r++) {
			if (board.cells[r][col] === CELL_FILLED) {
				if (curCombo === 0) {
					curComboStart = r;
				}
				curCombo++;
			} else {
				if (curCombo > 0) playerCombos.push({start: curComboStart, nr: curCombo});
				curCombo = 0;
			}
		}
	}

	if (curCombo > 0) playerCombos.push({start: curComboStart, nr: curCombo});

	return playerCombos;
}


/**
 * Returns which row hint/maker button is under the given screen coordinates.
 * @param {object} coords Object with the coordinates.
 * @returns The row and button index numbers, in an array. Returns null if none.
 */
function getRowBannerButtonInCoords(coords) {
	let hintsClickCoords = {
		x: coords.x - sectionCoords.rowBanner.x,
		y: coords.y - sectionCoords.rowBanner.y + cam.coords.y,
	};
	let row = Math.floor(hintsClickCoords.y / (CELL_SIZE + CELL_PADDING));

	if (row < 0 || row >= board.nrRows) return null;

	let button = Math.floor(hintsClickCoords.x / (CELL_SIZE + CELL_PADDING));
	let maxButtons = Math.floor(sectionCoords.rowBanner.width / (CELL_SIZE + CELL_PADDING));
	let nrButtonsInRow = 0;
	if (state === STATE_PLAYING) {
		nrButtonsInRow = board.rowHints[row].length;
	} else if (state === STATE_MAKING) {
		nrButtonsInRow = 3;
	}
	button -= maxButtons - nrButtonsInRow;

	if (button < 0) return null;
	if (state === STATE_PLAYING) {
		if (button >= board.rowHints[row].length) return null;
	} else if (state === STATE_MAKING) {
		if (button >= 3) return null;
	}

	return [row, button];
}


/**
 * Hides the input box HTML element in the middle of the canvas.
 */
function hideInputBox() {
	inputEl.style.display = 'none';
}


/**
 * Checks if a given point is inside a set of coordinates.
 * @param point Point to check, in the format {x, y}.
 * @param coords Coordinates to check, in the format {x, y, width, height}.
 * @param parentCoords If not undefined, then the previous coordinates are relative to these.
 */
function isPointInCoords(point, coords, parentCoords) {
	let finalCoords = {
		x: coords.x,
		y: coords.y,
		width: coords.width,
		height: coords.height,
	};

	if (parentCoords !== undefined) {
		let finalXY = calculateChildCoords(coords, parentCoords);
		let finalWH = calculateChildDimensions(coords, finalXY, parentCoords);
		finalCoords = {
			x: finalXY.x,
			y: finalXY.y,
			width: finalWH.width,
			height: finalWH.height,
		};
	}

	return point.x >= finalCoords.x &&
		point.y >= finalCoords.y &&
		point.x <= finalCoords.x + finalCoords.width &&
		point.y <= finalCoords.y + finalCoords.height;
}


/**
 * Loads the player's bookmark, if any, and returns its info.
 * @returns null if no data exists, or an object with the data otherwise.
 */
function getBookmarkData() {
	const bookmarkString = localStorage.getItem('pikcrossBookmark');

	if (bookmarkString === null || bookmarkString === '') return null;

	return decodeBoard(bookmarkString, BOARD_DATA_CONTEXT_BOOKMARK);
}


/**
 * Loads a level and initializes the board game screen.
 * @param {number} levelNumber Level number, or 0 for custom.
 * @param {string} levelCode Level code.
 * @param {number} stateContext What game state is this being loaded for?
 * @returns True on success, false on failure.
 */
function loadLevel(levelNumber, levelCode, stateContext) {
	if (stateContext === STATE_PLAYING && levelCode.length < 3) {
		return false;
	}

	let nrRowBannerCells = 0;
	let nrColBannerCells = 0;

	// Cleanup.
	cam.coords.x = 0;
	cam.coords.y = 0;

	board.name = 'Puzzle';
	board.nrRows = 0;
	board.nrCols = 0;
	board.cells = [];
	board.solution = [];
	board.rowHints = [];
	board.colHints = [];
	board.autoMarkedRows = [];
	board.autoMarkedCols = [];
	board.defiedRows = [];
	board.defiedCols = [];

	board.levelNumber = levelNumber;
	board.levelCode = levelCode;

	if (stateContext === STATE_PLAYING) {

		// Load board.
		let levelData = decodeBoard(levelCode, BOARD_DATA_CONTEXT_LEVEL);

		// Sanity check.
		if (levelData == null) return false;
		if (levelData.nrRows < MIN_COLS_OR_ROWS) return false;
		if (levelData.nrRows > MAX_COLS_OR_ROWS) return false;
		if (levelData.nrCols < MIN_COLS_OR_ROWS) return false;
		if (levelData.nrCols > MAX_COLS_OR_ROWS) return false;
		let hasFilledCells = false;
		for (let r = 0; r < levelData.nrRows; r++) {
			for (let c = 0; c < levelData.nrCols; c++) {
				if (levelData.cells[r][c] === CELL_FILLED) {
					hasFilledCells = true;
					break;
				}
			}
			if (hasFilledCells) break;
		}
		if (!hasFilledCells) return false;
		if (levelData.name.length === 0) levelData.name = 'Puzzle';

		board.nrRows = levelData.nrRows;
		board.nrCols = levelData.nrCols;
		board.solution = levelData.cells;
		board.name = levelData.name;

		// Hints.
		for (let r = 0; r < board.nrRows; r++) {
			board.rowHints.push([]);
			let hintNr = 0;
			for (let c = 0; c < board.nrCols; c++) {
				if (board.solution[r][c] === CELL_FILLED) {
					hintNr++;
				} else {
					if (hintNr > 0) {
						board.rowHints[r].push({state: CELL_BLANK, nr: hintNr});
					}
					hintNr = 0;
				}
			}
			if (hintNr > 0) {
				board.rowHints[r].push({state: CELL_BLANK, nr: hintNr});
			}
			nrRowBannerCells = Math.max(nrRowBannerCells, board.rowHints[r].length);
		}
		for (let c = 0; c < board.nrCols; c++) {
			board.colHints.push([]);
			let hintNr = 0;
			for (let r = 0; r < board.nrRows; r++) {
				if (board.solution[r][c] === CELL_FILLED) {
					hintNr++;
				} else {
					if (hintNr > 0) {
						board.colHints[c].push({state: CELL_BLANK, nr: hintNr});
					}
					hintNr = 0;
				}
			}
			if (hintNr > 0) {
				board.colHints[c].push({state: CELL_BLANK, nr: hintNr});
			}
			nrColBannerCells = Math.max(nrColBannerCells, board.colHints[c].length);
		}

	} else if (stateContext === STATE_MAKING) {

		// Create an empty board.
		board.nrRows = 10;
		board.nrCols = 10;
		for (let r = 0; r < board.nrRows; r++) {
			board.cells.push([]);
			for (let c = 0; c < board.nrCols; c++) {
				board.cells[r].push(CELL_BLANK);
			}
		}

		// Maker grid tools.
		nrRowBannerCells = 3;
		nrColBannerCells = 3;

	}

	// Current cells state.
	board.cells = [];
	for (let r = 0; r < board.nrRows; r++) {
		board.cells.push([]);
		for (let c = 0; c < board.nrCols; c++) {
			board.cells[r].push(CELL_BLANK);
		}
	}

	for (let r = 0; r < board.nrRows; r++) {
		board.autoMarkedRows.push(false);
		board.defiedRows.push(false);
	}
	for (let c = 0; c < board.nrCols; c++) {
		board.autoMarkedCols.push(false);
		board.defiedCols.push(false);
	}

	// Coordinates.
	sectionCoords.miniature.x = SECTION_PADDING;
	sectionCoords.miniature.y = SECTION_PADDING;
	sectionCoords.miniature.width = nrRowBannerCells * (CELL_SIZE + CELL_PADDING);
	sectionCoords.miniature.height = nrColBannerCells * (CELL_SIZE + CELL_PADDING);

	sectionCoords.footer.x = sectionCoords.miniature.x;
	sectionCoords.footer.y = CANVAS.HEIGHT - SECTION_PADDING - 64;
	sectionCoords.footer.width = CANVAS.WIDTH - SECTION_PADDING * 2;
	sectionCoords.footer.height = 64;

	sectionCoords.rowBanner.x = sectionCoords.miniature.x;
	sectionCoords.rowBanner.y = sectionCoords.miniature.y + sectionCoords.miniature.height + SECTION_PADDING;
	sectionCoords.rowBanner.width = sectionCoords.miniature.width;
	sectionCoords.rowBanner.height = sectionCoords.footer.y - SECTION_PADDING - sectionCoords.rowBanner.y;

	sectionCoords.colBanner.x = sectionCoords.miniature.x + sectionCoords.miniature.width + SECTION_PADDING;
	sectionCoords.colBanner.y = sectionCoords.miniature.y;
	sectionCoords.colBanner.width = CANVAS.WIDTH - SECTION_PADDING - sectionCoords.colBanner.x;
	sectionCoords.colBanner.height = sectionCoords.miniature.height;

	sectionCoords.board.x = sectionCoords.colBanner.x;
	sectionCoords.board.y = sectionCoords.rowBanner.y;
	sectionCoords.board.width = sectionCoords.colBanner.width;
	sectionCoords.board.height = sectionCoords.rowBanner.height;

	// Border gradients.
	const boardX2 = sectionCoords.board.x + sectionCoords.board.width;
	const boardY2 = sectionCoords.board.y + sectionCoords.board.height;

	borderGradients.left = canvas.createLinearGradient(sectionCoords.board.x, 0, sectionCoords.board.x + BORDER_GRADIENT_LENGTH, 0);
	borderGradients.left.addColorStop(1, 'rgba(0, 128, 0, 0.0)');
	borderGradients.left.addColorStop(0, 'rgba(0, 128, 0, 0.5)');

	borderGradients.right = canvas.createLinearGradient(boardX2, 0, boardX2 - BORDER_GRADIENT_LENGTH, 0);
	borderGradients.right.addColorStop(1, 'rgba(0, 128, 0, 0.0)');
	borderGradients.right.addColorStop(0, 'rgba(0, 128, 0, 0.5)');

	borderGradients.up = canvas.createLinearGradient(0, sectionCoords.board.y, 0, sectionCoords.board.y + BORDER_GRADIENT_LENGTH);
	borderGradients.up.addColorStop(1, 'rgba(0, 128, 0, 0.0)');
	borderGradients.up.addColorStop(0, 'rgba(0, 128, 0, 0.5)');

	borderGradients.down = canvas.createLinearGradient(0, boardY2, 0, boardY2 - BORDER_GRADIENT_LENGTH);
	borderGradients.down.addColorStop(1, 'rgba(0, 128, 0, 0.0)');
	borderGradients.down.addColorStop(0, 'rgba(0, 128, 0, 0.5)');

	if (stateContext === STATE_PLAYING) {
		for (let r = 0; r < board.nrRows; r++) {
			updateRow(r);
		}
		for (let c = 0; c < board.nrCols; c++) {
			updateColumn(c);
		}
	}

	return true;
}


/**
 * Loads the player's global progression.
 */
function loadProgression() {
	let progressionStr = localStorage.getItem('pikcrossProgression');
	if (progressionStr === null || progressionStr.length === 0) {
		return;
	}
	let reader = new StrBitReader(progressionStr);
	for (let l = 0; l < LEVELS.length; l++) {
		progression[l] = reader.readNumber(1) === 1;
	}
}


/**
 * Handler for when the player does an input down on the canvas.
 * This happens regardless of it being a mouse button down press, or a mobile touch start.
 * @param {number} button What button got pressed. 0 for left click/mobile touch, 2 for right click, other values for other buttons.
 * @param {boolean} touch True if it was a touch event, false otherwise.
 */
function onCanvasInputDown(button, touch) {
	for (let l = 0; l < loaded.length; l++) {
		if (!loaded[l]) return;
	}
	if (transitionAnimTime > 0) return;

	if (inPanel) {
		onCanvasInputDownInPanel();
		return;
	}

	switch (state) {
		case STATE_MAIN_MENU:
			onCanvasInputDownInMainMenu();
			break;
		case STATE_PLAYING:
		case STATE_MAKING:
			let changesMade = onCanvasInputDownInGameplay(button, touch);
			if (changesMade) {
				input.dragging = true;
				input.dragStart.x = input.screenCoords.x;
				input.dragStart.y = input.screenCoords.y;
			}
			break;
	}
}


/**
 * Handler for when the player does an input down on the canvas, in the board game screen.
 * This happens regardless of it being a mouse button down press, or a mobile touch start.
 * @param {number} button What button got pressed. 0 for left click/mobile touch, 2 for right click, other values for other buttons.
 * @param {boolean} touch True if it was a touch, false otherwise.
 * @returns True if something happened, false otherwise.
 */
function onCanvasInputDownInGameplay(button, touch) {
	let changesMade = false;

	// Figure out where the player clicked.
	if (isPointInCoords(input.screenCoords, sectionCoords.board)) {
		// Clicked on the board.

		let idxs = getCellInCoords(input.screenCoords);
		if (touch) {
			if (toggleCellFillAndMark(idxs[0], idxs[1])) {
				changesMade = true;
			}
		} else if (button === 0) {
			if (toggleCellFill(idxs[0], idxs[1])) {
				changesMade = true;
			}
		} else if (button === 2 && state === STATE_PLAYING) {
			if (toggleCellMark(idxs[0], idxs[1])) {
				changesMade = true;
			}
		}

	} else if (
		state === STATE_PLAYING &&
		isPointInCoords(input.screenCoords, sectionCoords.rowBanner)
	) {
		// Clicked on the row hints.

		let idxs = getRowBannerButtonInCoords(input.screenCoords);
		if (idxs !== null && button === 0) {
			if (toggleRowHint(idxs[0], idxs[1])) {
				updateRow(idxs[0]);
				saveBookmark();
				changesMade = true;
			}
		}

	} else if (
		state === STATE_PLAYING &&
		isPointInCoords(input.screenCoords, sectionCoords.colBanner)
	) {
		// Clicked on the column hints.

		let idxs = getColumnBannerButtonInCoords(input.screenCoords);
		if (idxs !== null && button === 0) {
			if (toggleColumnHint(idxs[0], idxs[1])) {
				updateColumn(idxs[0]);
				saveBookmark();
				changesMade = true;
			}
		}

	} else if (
		state === STATE_MAKING &&
		isPointInCoords(input.screenCoords, sectionCoords.rowBanner)
	) {
		// Clicked on the maker mode row buttons.

		let idxs = getRowBannerButtonInCoords(input.screenCoords);
		if (idxs !== null && button === 0) {
			if (idxs[1] === 0) {
				// Delete row.
				if (board.nrRows > MIN_COLS_OR_ROWS) {
					board.cells.splice(idxs[0], 1);
					board.nrRows--;
					clampCamera();
					changesMade = true;
				}
			} else if (idxs[1] === 1) {
				// New row above.
				if (board.nrRows < MAX_COLS_OR_ROWS) {
					let newCol = [];
					for (let c = 0; c < board.nrCols; c++) {
						newCol.push(CELL_BLANK);
					}
					board.cells.splice(idxs[0], 0, newCol);
					board.nrRows++;
					changesMade = true;
				}
			} else if (idxs[1] === 2) {
				// New row below.
				if (board.nrRows < MAX_COLS_OR_ROWS) {
					let newCol = [];
					for (let c = 0; c < board.nrCols; c++) {
						newCol.push(CELL_BLANK);
					}
					board.cells.splice(idxs[0] + 1, 0, newCol);
					board.nrRows++;
					changesMade = true;
				}
			}
		}

	} else if (
		state === STATE_MAKING &&
		isPointInCoords(input.screenCoords, sectionCoords.colBanner)
	) {
		// Clicked on the maker mode column buttons.

		let idxs = getColumnBannerButtonInCoords(input.screenCoords);
		if (idxs != null && button === 0) {
			if (idxs[1] === 0) {
				// Delete column.
				if (board.nrCols > MIN_COLS_OR_ROWS) {
					for (let r = 0; r < board.nrRows; r++) {
						board.cells[r].splice(idxs[0], 1);
					}
					board.nrCols--;
					clampCamera();
					changesMade = true;
				}
			} else if (idxs[1] === 1) {
				// New column above.
				if (board.nrCols < MAX_COLS_OR_ROWS) {
					for (let r = 0; r < board.nrRows; r++) {
						board.cells[r].splice(idxs[0], 0, CELL_BLANK);
					}
					board.nrCols++;
					changesMade = true;
				}
			} else if (idxs[1] === 2) {
				// New column below.
				if (board.nrCols < MAX_COLS_OR_ROWS) {
					for (let r = 0; r < board.nrRows; r++) {
						board.cells[r].splice(idxs[0] + 1, 0, CELL_BLANK);
					}
					board.nrCols++;
					changesMade = true;
				}
			}
		}

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.pause, sectionCoords.footer)) {
		// Clicked on the pause button.

		inPanel = true;
		panelState = PANEL_STATE_PAUSE;
		changesMade = true;

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.zoomIn, sectionCoords.footer)) {
		// Clicked on the zoom in button.

		cam.zoom += 0.2;

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.zoomOut, sectionCoords.footer)) {
		// Clicked on the zoom out button.

		cam.zoom -= 0.2;

	}

	if (!changesMade && !touch) {
		input.dragPanning = true;
		changesMade = true;
	}

	if (changesMade) {
		updateCanvas();
	}

	return changesMade;
}


/**
 * Handler for when the player does an input down on the canvas, in the main menu.
 * This happens regardless of it being a mouse button down press, or a mobile touch start.
 */
function onCanvasInputDownInMainMenu() {
	if (isPointInCoords(input.screenCoords, guiItemCoords.mainMenuContinue, sectionCoords.mainMenuHeader)) {
		// Clicked on the continue button.
		const bookmarkData = getBookmarkData();
		if (bookmarkData == null) return;

		if (loadLevel(bookmarkData.levelNumber, bookmarkData.levelCode, STATE_PLAYING)) {

			board.cells = bookmarkData.cells;
			let hintIdx = 0;
			for (let r = 0; r < board.nrRows; r++) {
				for (let h = 0; h < board.rowHints[r].length; h++) {
					board.rowHints[r][h].state = bookmarkData.rowHints[hintIdx];
					hintIdx++;
				}
			}
			hintIdx = 0;
			for (let c = 0; c < board.nrCols; c++) {
				for (let h = 0; h < board.colHints[c].length; h++) {
					board.colHints[c][h].state = bookmarkData.colHints[hintIdx];
					hintIdx++;
				}
			}

			for (let c = 0; c < board.nrCols; c++) {
				updateColumn(c);
			}
			for (let r = 0; r < board.nrRows; r++) {
				updateRow(r);
			}

			changeState(STATE_PLAYING);

		} else {

			panelState = PANEL_STATE_LOAD_ERROR;

		}

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.mainMenuInfo, sectionCoords.mainMenuHeader)) {
		// Clicked on the info button.
		inPanel = true;
		panelState = PANEL_STATE_INFO;

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.mainMenuMake, sectionCoords.mainMenuHeader)) {
		// Clicked on the make custom button.
		loadLevel(0, '', STATE_MAKING);
		changeState(STATE_MAKING);

	} else if (isPointInCoords(input.screenCoords, guiItemCoords.mainMenuCustom, sectionCoords.mainMenuHeader)) {
		// Clicked on the play custom button.
		let bookmarkData = getBookmarkData();
		if (bookmarkData != null && !gaveBookmarkWarning) {
			inPanel = true;
			panelState = PANEL_STATE_BOOKMARK_WARNING;
			gaveBookmarkWarning = true;
		} else {
			inPanel = true;
			panelState = PANEL_STATE_PLAYING_CODE;
			inputEl.value = '';
			showInputBox(false);
		}

	} else if (firstSideCleared() && isPointInCoords(input.screenCoords, guiItemCoords.mainMenuSide, sectionCoords.mainMenuHeader)) {
		// Clicked on the side swapping button.
		gameSide = gameSide === 0 ? 1 : 0;
		updateCanvas();

	} else {
		// Check if the player clicked on one of the level buttons.
		let buttonPadding = SECTION_PADDING * 2;
		let buttonWidth = (sectionCoords.levelSelect.width - buttonPadding * 5) / 4;
		let levelHeight = (sectionCoords.levelSelect.height - buttonPadding * 5) / 4;
		let buttonHeight = levelHeight * 0.75;

		let clickedLevelIdx = -1;
		for (let c = 0; c < 4; c++) {
			for (let r = 0; r < 4; r++) {
				if (isPointInCoords(
					input.screenCoords,
					{
						x: buttonPadding + c * (buttonWidth + buttonPadding),
						y: buttonPadding + r * (levelHeight + buttonPadding),
						width: buttonWidth,
						height: buttonHeight,
					},
					sectionCoords.levelSelect)) {
					clickedLevelIdx = r * 4 + c;
					clickedLevelIdx += 16 * gameSide;
					break;
				}
			}
			if (clickedLevelIdx !== -1) {
				break;
			}
		}

		if (clickedLevelIdx !== -1) {
			let bookmarkData = getBookmarkData();
			if (bookmarkData != null && !gaveBookmarkWarning) {
				inPanel = true;
				panelState = PANEL_STATE_BOOKMARK_WARNING;
				gaveBookmarkWarning = true;
			} else {
				if (loadLevel(clickedLevelIdx + 1, LEVELS[clickedLevelIdx], STATE_PLAYING)) {
					changeState(STATE_PLAYING);
					clearBookmark();
				} else {
					panelState = PANEL_STATE_LOAD_ERROR;
				}
			}
		}
	}
}


/**
 * Handler for when the player does an input down on the canvas, in the panel.
 * This happens regardless of it being a mouse button down press, or a mobile touch start.
 */
function onCanvasInputDownInPanel() {
	// Figure out where the player clicked.
	switch (panelState) {
		case PANEL_STATE_PAUSE:

			if (isPointInCoords(input.screenCoords, guiItemCoords.continue, sectionCoords.panel)) {
				// Clicked on the continue button.

				inPanel = false;

			} else if (isPointInCoords(input.screenCoords, guiItemCoords.restart, sectionCoords.panel)) {
				// Clicked on the restart button.

				inPanel = false;
				loadLevel(board.levelNumber, board.levelCode, state);
				clearBookmark();

			} else if (isPointInCoords(input.screenCoords, guiItemCoords.finish, sectionCoords.panel)) {
				// Clicked on the finish button.

				if (state === STATE_MAKING) {
					let hasFilledCells = false;
					for (let r = 0; r < board.nrRows; r++) {
						for (let c = 0; c < board.nrCols; c++) {
							if (board.cells[r][c] === CELL_FILLED) {
								hasFilledCells = true;
								break;
							}
						}
						if (hasFilledCells) break;
					}

					if (hasFilledCells) {
						panelState = PANEL_STATE_MAKING_NAME;
						inputEl.value = board.name;
						showInputBox(false, 20);
					} else {
						panelState = PANEL_STATE_SAVE_ERROR;
					}
				}

			} else if (isPointInCoords(input.screenCoords, guiItemCoords.quit, sectionCoords.panel)) {
				// Clicked on the quit button.

				changeState(STATE_MAIN_MENU, function () {
					inPanel = false;
				});

			} else {
				return;

			}
			break;

		case PANEL_STATE_CONGRATS:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				changeState(STATE_MAIN_MENU, function () {
					inPanel = false;
				});

			}

			break;

		case PANEL_STATE_MAKING_NAME:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				acceptInputBox();
			}

			break;

		case PANEL_STATE_MAKING_CODE:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				acceptInputBox();

			}

			break;

		case PANEL_STATE_PLAYING_CODE:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				acceptInputBox();

			}

			break;

		case PANEL_STATE_LOAD_ERROR:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				inPanel = false;

			}

			break;

		case PANEL_STATE_SAVE_ERROR:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				inPanel = false;

			}

			break;

		case PANEL_STATE_BOOKMARK_WARNING:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				inPanel = false;

			}

			break;

		case PANEL_STATE_INFO:

			if (isPointInCoords(input.screenCoords, guiItemCoords.ok, sectionCoords.panel)) {
				// Clicked on the ok button.

				inPanel = false;

			}

			break;
	}

	updateCanvas();
}


/**
 * Handler for when the player does an input move on the canvas.
 * This happens regardless of it being a mouse button down press, or a mobile touch start.
 */
function onCanvasInputMove() {
	if (input.dragging && !input.dragPanning) {

		let snappedMouse = {x: input.screenCoords.x, y: input.screenCoords.y};
		let somethingChanged = false;
		if (input.dragLockCoord.x) snappedMouse.x = input.dragStart.x;
		if (input.dragLockCoord.y) snappedMouse.y = input.dragStart.y;

		if (
			isPointInCoords(input.dragStart, sectionCoords.board) &&
			isPointInCoords(input.screenCoords, sectionCoords.board)
		) {
			let idxs = getCellInCoords(snappedMouse);
			if (idxs != null) {
				if (toggleCellFill(idxs[0], idxs[1])) {
					somethingChanged = true;
				}
			}

		} else if (
			state === STATE_PLAYING &&
			isPointInCoords(input.dragStart, sectionCoords.rowBanner) &&
			isPointInCoords(input.screenCoords, sectionCoords.rowBanner)
		) {
			let idxs = getRowBannerButtonInCoords(snappedMouse);
			if (idxs != null) {
				if (toggleRowHint(idxs[0], idxs[1])) {
					updateRow(idxs[0]);
					somethingChanged = true;
				}
			}

		} else if (
			state === STATE_PLAYING &&
			isPointInCoords(input.dragStart, sectionCoords.colBanner) &&
			isPointInCoords(input.screenCoords, sectionCoords.colBanner)
		) {
			let idxs = getColumnBannerButtonInCoords(snappedMouse);
			if (idxs != null) {
				if (toggleColumnHint(idxs[0], idxs[1])) {
					updateColumn(idxs[0]);
					somethingChanged = true;
				}
			}
		}

		if (somethingChanged) {
			if (!input.dragLockCoord.x && !input.dragLockCoord.y) {
				// This means we changed something that wasn't the thing the drag start changed.
				// We should lock the coordinates.
				let axisSnappedMouse = snapCoordsToAxis(input.screenCoords, input.dragStart);
				if (input.dragStart.x === axisSnappedMouse.x) {
					input.dragLockCoord.x = true;
				} else {
					input.dragLockCoord.y = true;
				}
			}
			updateCanvas();
		}

	}
}


/**
 * Handler for when the player presses the mouse button down on the canvas.
 * @param {MouseEvent} event Event that triggered this callback.
 */
function onCanvasMouseDown(event) {
	if (event.button === 1) {
		event.preventDefault();
	}
	updateInputFromMouse(event);
	onCanvasInputDown(event.button, false);
}


/**
 * Handler for when the player moves the mouse cursor on the canvas.
 * @param {MouseEvent} event Event that triggered this callback.
 */
function onCanvasMouseMove(event) {
	updateInputFromMouse(event);
	onCanvasInputMove();
}


/**
 * Handler for when the player moves a mobile touch on the canvas.
 * @param {TouchEvent} event Event that triggered this callback.
 */
function onCanvasTouchMove(event) {
	updateInputFromTouch(event);
	onCanvasInputMove();
}


/**
 * Handler for when the player starts a mobile touch on the canvas.
 * @param {TouchEvent} event Event that triggered this callback.
 */
function onCanvasTouchStart(event) {
	updateInputFromTouch(event);
	onCanvasInputDown(0, true);
}


/**
 * Handler for when the player does an input move on the page.
 * This happens regardless of it being a mouse cursor move, or a mobile touch move.
 */
function onPageInputMove() {
	if (input.dragging && input.dragPanning) {
		cam.coords.x -= input.screenCoords.x - input.dragStart.x;
		cam.coords.y -= input.screenCoords.y - input.dragStart.y;
		input.dragStart.x = input.screenCoords.x;
		input.dragStart.y = input.screenCoords.y;
		clampCamera();
		updateCanvas();
	}
}


/**
 * Handler for when the player does an input up on the page.
 * This happens regardless of it being a mouse button up press, or a mobile touch end.
 */
function onPageInputUp() {
	input.dragging = false;
	input.dragAction = -1;
	input.dragLockCoord.x = false;
	input.dragLockCoord.y = false;
	if (input.dragPanning) {
		input.dragPanning = false;
		if (Math.abs(cam.coords.x) < 10) {
			cam.coords.x = 0;
		}
		if (Math.abs(cam.coords.y) < 10) {
			cam.coords.y = 0;
		}
	}
	updateCanvas();
}


/**
 * Handler for when the player moves their mouse on the page.
 * @param {MouseEvent} event Event that triggered this callback.
 */
function onPageMouseMove(event) {
	updateInputFromMouse(event);
	onPageInputMove();
}


/**
 * Handler for when the player releases their mouse button on the page.
 */
function onPageMouseUp() {
	onPageInputUp();
}


/**
 * Handler for when the player moves their mobile touch on the page.
 * @param {TouchEvent} event Event that triggered this callback.
 */
function onPageTouchMove(event) {
	updateInputFromTouch(event);
	onPageInputMove();
}


/**
 * Handler for when the player releases their mobile touch on the page.
 * @param {TouchEvent} event Event that triggered this callback.
 */
function onPageTouchEnd(event) {
	if (event.target === canvasEl) {
		event.preventDefault();
	}
	onPageInputUp();
}


/**
 * Handler for when the global game timer ticks.
 */
function onTimer() {
	if (transitionAnimTime > 0) {

		transitionAnimTime -= 1 / 60;
		if (transitionAnimTime <= 0) {
			if (transitionPhase === 0) {
				transitionPhase = 1;
				transitionAnimTime = TRANSITION_DURATION;
				state = transitionNewState;
				if (transitionCodeAfter !== undefined) transitionCodeAfter();
			} else {
				transitionAnimTime = 0;
			}
		}
		updateCanvas();

	}
}


/**
 * Checks whether one of the player board's rows or columns defies the corresponding solution hint.
 * @param {number|null} row Row number, if we're checking a row. null otherwise.
 * @param {number|null} col Column number, if we're checking a column. null otherwise.
 * @param {array} playerCombos Array with the player's combos for this column/row.
 * @param {array} hints Array of hints for this column/row.
 * @returns True if it defies, false otherwise.
 */
function playerLineDefiesHints(row, col, playerCombos, hints) {
	// Setup.
	let playerCells = [];
	if (row !== null) {
		for (let c = 0; c < board.nrCols; c++) {
			let cell = board.cells[row][c];
			if (cell === CELL_BLANK && board.autoMarkedCols[c]) cell = CELL_MARKED;
			playerCells.push(cell);
		}
	} else {
		for (let r = 0; r < board.nrRows; r++) {
			let cell = board.cells[r][col];
			if (cell === CELL_BLANK && board.autoMarkedRows[r]) cell = CELL_MARKED;
			playerCells.push(cell);
		}
	}

	// Start by checking if any marked cell is in the way of the hints.
	let curCellIdx = 0;
	for (let h = 0; h < hints.length;) {
		if (curCellIdx >= playerCells.length) {
			// We've reached the end of the cells, and we still have hints to check. Too bad.
			return true;
		}
		let markedCellIdx = findMarkedCellInList(playerCells, curCellIdx, hints[h].nr);
		if (markedCellIdx === -1) {
			// No marked cell. Move on to the next hint.
			curCellIdx += hints[h].nr + 1;
			h++;
		} else {
			// We've hit a marked cell. Let's move to after that cell and retry.
			curCellIdx = markedCellIdx + 1;
		}
	}

	if (playerCombos.length > hints.length) {
		//return true;

	} else if (playerCombos.length === hints.length) {
		for (let c = 0; c < playerCombos.length; c++) {
			if (playerCombos[c] > hints[c].nr) return true;
		}

	} else {
		let highestPlayerCombo = 0;
		let highestHintCombo = 0;

		for (let c = 0; c < playerCombos.length; c++) {
			highestPlayerCombo = Math.max(highestPlayerCombo, playerCombos[c]);
		}
		for (let h = 0; h < hints.length; h++) {
			highestHintCombo = Math.max(highestHintCombo, hints[h].nr);
		}

		if (highestPlayerCombo > highestHintCombo) return true;

	}

	// All good!
	return false;
}


/**
 * Checks whether one of the player board's rows or columns matches the corresponding solution hint.
 * This can be true even if the player marked the wrong cells.
 * @param {array} playerCombos Array with the player's combos for this column/row.
 * @param {array} hints Array of hints for this column/row.
 * @returns True if it matches, false otherwise.
 */
function playerLineMatchesHints(playerCombos, hints) {
	if (playerCombos.length !== hints.length) return false;

	for (let h = 0; h < hints.length; h++) {
		if (hints[h].nr !== playerCombos[h].nr) return false;
	}

	return true;
}


/**
 * Populates the board with cells from the given code, in the puzzle making state.
 * Debug function.
 * @param {string} levelCode Code.
 * @returns True on success, false on failure.
 */
function populateFromCode(levelCode) {
	if (state !== STATE_MAKING) return false;
	let levelData = decodeBoard(levelCode, BOARD_DATA_CONTEXT_LEVEL);
	if (levelData == null) return false;

	board.nrRows = levelData.nrRows;
	board.nrCols = levelData.nrCols;
	board.cells = levelData.cells;
	board.name = levelData.name;
	updateCanvas();
	return true;
}


/**
 * Saves the player's bookmark.
 */
function saveBookmark() {
	if (state !== STATE_PLAYING) return;

	const boardData = {
		nrRows: board.nrRows,
		nrCols: board.nrCols,
		cells: board.cells,
		nrRowHints: 0,
		nrColHints: 0,
		rowHints: [],
		colHints: [],
		name: board.name,
		levelNumber: board.levelNumber,
		levelCode: board.levelCode,
	};
	for (let r = 0; r < board.nrRows; r++) {
		for (let h = 0; h < board.rowHints[r].length; h++) {
			boardData.rowHints.push(board.rowHints[r][h].state);
		}
	}
	boardData.nrRowHints = boardData.rowHints.length;
	for (let c = 0; c < board.nrCols; c++) {
		for (let h = 0; h < board.colHints[c].length; h++) {
			boardData.colHints.push(board.colHints[c][h].state);
		}
	}
	boardData.nrColHints = boardData.colHints.length;
	let boardStr = encodeBoard(boardData, BOARD_DATA_CONTEXT_BOOKMARK);
	localStorage.setItem('pikcrossBookmark', boardStr);

	gaveBookmarkWarning = false;
}


/**
 * Saves the player's global progression.
 */
function saveProgression() {
	let writer = new StrBitWriter();
	for (let l = 0; l < LEVELS.length; l++) {
		writer.writeNumber(progression[l] ? 1 : 0, 1);
	}
	localStorage.setItem('pikcrossProgression', writer.getStr());
}


/**
 * Sets up the whole game.
 */
function pikcrossSetup() {
	// HTML setup.
	let pikcrossDiv = document.getElementById('pikcross');
	if (pikcrossDiv == null) return;
	pikcrossDiv.style.position = 'relative';
	pikcrossDiv.style.width = CANVAS.WIDTH + 'px';
	pikcrossDiv.style.height = CANVAS.HEIGHT + 'px';

	canvasEl = document.createElement('canvas');
	canvasEl.width = CANVAS.WIDTH;
	canvasEl.height = CANVAS.HEIGHT;
	pikcrossDiv.appendChild(canvasEl);

	inputEl = document.createElement('input');
	inputEl.type = 'text';
	inputEl.style.left = '30%';
	inputEl.style.width = '40%';
	inputEl.style.top = '48%';
	inputEl.style.height = '4%';
	inputEl.style.position = 'absolute';
	inputEl.style.backgroundColor = '#BDB';
	inputEl.style.fontFamily = 'monospace';
	inputEl.addEventListener('focus', function (e) {
		e.target.select();
	});
	inputEl.addEventListener('keyup', function (e) {
		if (e.key === 'Enter') acceptInputBox();
	});
	hideInputBox();
	pikcrossDiv.appendChild(inputEl);

	canvas = canvasEl.getContext('2d');
	canvas.width = CANVAS.WIDTH;
	canvas.height = CANVAS.HEIGHT;
	canvas.textAlign = 'center';
	canvas.textBaseline = 'middle';

	// Listeners.
	canvasEl.addEventListener('mousedown', onCanvasMouseDown);
	canvasEl.addEventListener('touchstart', onCanvasTouchStart);
	canvasEl.addEventListener('mousemove', onCanvasMouseMove);
	canvasEl.addEventListener('touchmove', onCanvasTouchMove);
	canvasEl.addEventListener('contextmenu', function (e) {
		e.preventDefault();
	}, false);
	document.addEventListener('mousemove', onPageMouseMove);
	document.addEventListener('touchmove', onPageTouchMove);
	document.addEventListener('mouseup', onPageMouseUp);
	document.addEventListener('touchend', onPageTouchEnd);

	// Spritesheet: [[File:Pikcross spritesheet.png]]
	sprites = new Image();
	sprites.onload = function () {
		loaded[LOAD_CONTENT_SPRITES] = true;
		updateCanvas();
	};
	sprites.src = 'https://mario.wiki.gallery/images/c/c7/Pikcross_spritesheet.png';

	// Player progression.
	for (let l = 0; l < LEVELS.length; l++) {
		progression.push(false);
	}
	loadProgression();

	// Levels data.
	for (let l = 0; l < LEVELS.length; l++) {
		levelsData.push(decodeBoard(LEVELS[l], BOARD_DATA_CONTEXT_LEVEL));
	}

	// Global timer.
	setInterval(onTimer, 1000 / 60);

	loaded[LOAD_CONTENT_SETUP] = true;
}


/**
 * Shows the input box HTML element in the middle of the canvas.
 * It also selects the text.
 * @param {boolean} readOnly Whether the box should be read only or not.
 * @param {number} maxLength Maximum length for the input box. undefined for default.
 */
function showInputBox(readOnly, maxLength = undefined) {
	inputEl.style.display = 'block';

	inputEl.removeAttribute('maxlength');
	inputEl.removeAttribute('readonly');
	if (maxLength > 0) inputEl.maxLength = maxLength;
	if (readOnly) inputEl.readOnly = true;

	// This weird hack is necessary for some reason.
	window.setTimeout(
		function () {
			inputEl.focus({focusVisible: true});
			inputEl.select();
		},
		0,
	);
}


/**
 * Given a set of coordinates, it snaps them, so they are in the same axis as the anchor coordinates.
 * @param {object} coords Coordinates to snap.
 * @param {object} anchor Coordinates to compare against.
 * @returns An object with the snapped coordinates.
 */
function snapCoordsToAxis(coords, anchor) {
	let h_diff = Math.abs(coords.x - anchor.x);
	let v_diff = Math.abs(coords.y - anchor.y);
	if (h_diff > v_diff) {
		return {x: coords.x, y: anchor.y};
	} else {
		return {x: anchor.x, y: coords.y};
	}
}


/**
 * Solves the puzzle.
 * Debug function.
 */
function solve() {
	board.cells = board.solution;
	for (let c = 0; c < board.nrCols; c++) {
		updateColumn(c);
	}
	for (let r = 0; r < board.nrRows; r++) {
		updateRow(r);
	}
	updateCanvas();
}


/**
 * Toggles whether a cell is filled or blank, if possible.
 * @param {number} row Row index number.
 * @param {number} col Column index number.
 * @returns True if the cell changed, false otherwise.
 */
function toggleCellFill(row, col) {
	if (row < 0 || row >= board.nrRows) return false;
	if (col < 0 || col >= board.nrCols) return false;

	if (input.dragAction === -1) {
		// Figure out what the player wants to do in this move.
		if (board.cells[row][col] === CELL_FILLED) {
			input.dragAction = CELL_BLANK;
		} else {
			input.dragAction = CELL_FILLED;
		}
	}

	return changeCell(row, col, input.dragAction);
}


/**
 * Toggles whether a cell is filled, marked, or blank, if possible.
 * @param {number} row Row index number.
 * @param {number} col Column index number.
 * @returns True if the cell changed, false otherwise.
 */
function toggleCellFillAndMark(row, col) {
	if (row < 0 || row >= board.nrRows) return false;
	if (col < 0 || col >= board.nrCols) return false;

	if (input.dragAction === -1) {
		// Figure out what the player wants to do in this move.
		if (board.cells[row][col] === CELL_FILLED) {
			input.dragAction = CELL_MARKED;
		} else if (board.cells[row][col] === CELL_MARKED) {
			input.dragAction = CELL_BLANK;
		} else {
			input.dragAction = CELL_FILLED;
		}
	}

	return changeCell(row, col, input.dragAction);
}


/**
 * Toggles whether a cell is marked or blank, if possible.
 * @param {number} row Row index number.
 * @param {number} col Column index number.
 * @returns True if the cell changed, false otherwise.
 */
function toggleCellMark(row, col) {
	if (row < 0 || row >= board.nrRows) return false;
	if (col < 0 || col >= board.nrCols) return false;

	if (input.dragAction === -1) {
		// Figure out what the player wants to do in this move.
		if (board.cells[row][col] === CELL_MARKED) {
			input.dragAction = CELL_BLANK;
		} else {
			input.dragAction = CELL_MARKED;
		}
	}

	return changeCell(row, col, input.dragAction);
}


/**
 * Toggles whether a column hint is marked or blank, if possible.
 * @param {number} col Column index number.
 * @param {number} hint Hint index number.
 * @returns True if the hint changed, false otherwise.
 */
function toggleColumnHint(col, hint) {
	if (input.dragAction === -1) {
		// Figure out what the player wants to do in this move.
		if (board.colHints[col][hint].state === CELL_FILLED) {
			input.dragAction = CELL_BLANK;
		} else {
			input.dragAction = CELL_FILLED;
		}
	}

	if (board.colHints[col][hint].state !== input.dragAction) {
		board.colHints[col][hint].state = input.dragAction;
		return true;
	}
	return false;
}


/**
 * Toggles whether a row hint is marked or blank, if possible.
 * @param {number} row Row index number.
 * @param {number} hint Hint index number.
 * @returns True if the hint changed, false otherwise.
 */
function toggleRowHint(row, hint) {
	if (input.dragAction === -1) {
		// Figure out what the player wants to do in this move.
		if (board.rowHints[row][hint].state === CELL_FILLED) {
			input.dragAction = CELL_BLANK;
		} else {
			input.dragAction = CELL_FILLED;
		}
	}

	if (board.rowHints[row][hint].state !== input.dragAction) {
		board.rowHints[row][hint].state = input.dragAction;
		return true;
	}
	return false;
}


/**
 * Updates the contents of the canvas.
 */
function updateCanvas() {
	for (let l = 0; l < loaded.length; l++) {
		if (!loaded[l]) return;
	}

	// Basic setup.
	canvas.setTransform(1, 0, 0, 1, 0, 0);
	canvas.fillStyle = COLOR_BG;
	canvas.fillRect(0, 0, CANVAS.WIDTH, CANVAS.HEIGHT);

	switch (state) {
		case STATE_MAIN_MENU:
			drawMainMenu();
			break;
		case STATE_PLAYING:
			drawBoard();
			break;
		case STATE_MAKING:
			drawBoard();
			break;
	}
	if (inPanel) {
		drawPanel();
	}
	if (transitionAnimTime > 0) {
		canvas.fillStyle = 'black';
		let fillRatio = 1 - transitionAnimTime / TRANSITION_DURATION;
		if (transitionPhase === 1) fillRatio = 1 - fillRatio;
		fillRatio *= 0.5;
		canvas.fillRect(0, 0, CANVAS.WIDTH, CANVAS.HEIGHT * fillRatio);
		canvas.fillRect(0, 0, CANVAS.WIDTH * fillRatio, CANVAS.HEIGHT);
		canvas.fillRect(CANVAS.WIDTH * (1 - fillRatio), 0, CANVAS.WIDTH, CANVAS.HEIGHT);
		canvas.fillRect(0, CANVAS.HEIGHT * (1 - fillRatio), CANVAS.WIDTH, CANVAS.HEIGHT);
	}
}


/**
 * Updates the state of a given column.
 * @param {number} col Column number to update.
 */
function updateColumn(col) {
	let playerCombos = getPlayerLineCombos(null, col);
	board.autoMarkedCols[col] = allHintsAreFilled(board.colHints[col]) && playerLineMatchesHints(playerCombos, board.colHints[col]);
	board.defiedCols[col] = playerLineDefiesHints(null, col, playerCombos, board.colHints[col]);
}


/**
 * Updates the variables that hold the mouse state.
 * @param {MouseEvent} event The event that triggered this.
 */
function updateInputFromMouse(event) {
	let br = canvasEl.getBoundingClientRect();
	input.screenCoords.x = event.clientX - br.left;
	input.screenCoords.y = event.clientY - br.top;
	input.worldCoords.x = input.screenCoords.x;
	input.worldCoords.y = input.screenCoords.y;
	input.worldCoords.x -= CANVAS.WIDTH / 2;
	input.worldCoords.y -= CANVAS.HEIGHT / 2;
	//input.worldCoords.x /= cam.zoom;
	//input.worldCoords.y /= cam.zoom;
	input.worldCoords.x += cam.coords.x;
	input.worldCoords.y += cam.coords.y;
}


/**
 * Updates the variables that hold the mouse state, using data from a mobile touch event.
 * @param {TouchEvent} event The event that triggered this.
 */
function updateInputFromTouch(event) {
	let br = canvasEl.getBoundingClientRect();
	input.screenCoords.x = event.touches[0].clientX - br.left;
	input.screenCoords.y = event.touches[0].clientY - br.top;
	input.worldCoords.x = input.screenCoords.x;
	input.worldCoords.y = input.screenCoords.y;
	input.worldCoords.x -= CANVAS.WIDTH / 2;
	input.worldCoords.y -= CANVAS.HEIGHT / 2;
	//input.worldCoords.x /= cam.zoom;
	//input.worldCoords.y /= cam.zoom;
	input.worldCoords.x += cam.coords.x;
	input.worldCoords.y += cam.coords.y;
}


/**
 * Updates the state of a given row.
 * @param {number} row Row number to update.
 */
function updateRow(row) {
	let playerCombos = getPlayerLineCombos(row, null);
	board.autoMarkedRows[row] = allHintsAreFilled(board.rowHints[row]) && playerLineMatchesHints(playerCombos, board.rowHints[row]);
	board.defiedRows[row] = playerLineDefiesHints(row, null, playerCombos, board.rowHints[row]);
}


/**
 * Makes writing bits into a string (8-bit int array) easy.
 */
class StrBitWriter {
	// Final string.
	#str = '';
	// Current working byte value.
	#byteValue = 0;
	// Current bit's index.
	#bitIdx = 0;

	/**
	 * Encodes a number into the string.
	 * @param {number} number Number to encode. 0 to 8 bits long.
	 * @param {number} nrBits Amount of bits the number takes. 8 by default.
	 */
	writeNumber(number, nrBits = 8) {
		if (this.#bitIdx + nrBits > 8) {
			this.nextByte();
		}
		for (let b = 0; b < nrBits; b++) {
			let bitValue = (number & 1 << b) > 0;
			this.#byteValue |= bitValue << this.#bitIdx;
			this.#bitIdx++;
		}
		if (this.#bitIdx >= 8) {
			this.nextByte();
		}
	}

	/**
	 * Finishes writing to the current byte and goes to the next one.
	 */
	nextByte() {
		this.#str += String.fromCharCode(this.#byteValue);
		this.#byteValue = 0;
		this.#bitIdx = 0;
	}

	/**
	 * Obtains the finalized string (8-bit int array).
	 * @returns The finalized string.
	 */
	getStr() {
		let str = this.#str;
		if (this.#bitIdx > 0) {
			str += String.fromCharCode(this.#byteValue);
		}
		return str;
	}

	/**
	 * Resets the state of the writer.
	 */
	reset() {
		this.#str = '';
		this.#byteValue = 0;
		this.#bitIdx = 0;
	}
}


/**
 * Makes reading bits from a string (8-bit int array) easy.
 */
class StrBitReader {
	// Full string.
	#str = '';
	// Current byte's index.
	#byteIdx = 0;
	// Current bit's index.
	#bitIdx = 0;

	/**
	 * Constructs a new instance.
	 * @param {string} str String (8-bit int array) with the bits to read from.
	 */
	constructor(str) {
		this.#str = str;
	}

	/**
	 * Reads a number from the next bits in the string.
	 * @param {number} nrBits Amount of bits the number takes. 8 by default.
	 * @returns The read number.
	 */
	readNumber(nrBits = 8) {
		if (this.#bitIdx + nrBits > 8) {
			this.nextByte();
		}
		let number = 0;
		let byteValue = this.#str.charCodeAt(this.#byteIdx);
		for (let b = 0; b < nrBits; b++) {
			let bitMask = 1 << this.#bitIdx;
			let bitValue = (byteValue & bitMask) > 0;
			number |= bitValue << b;
			this.#bitIdx++;
		}
		if (this.#bitIdx >= 8) {
			this.nextByte();
		}
		return number;
	}

	/**
	 * Finishes reading the current byte and goes to the next one.
	 */
	nextByte() {
		this.#byteIdx++;
		this.#bitIdx = 0;
	}

	/**
	 * Resets the state of the reader.
	 */
	reset() {
		this.#str = '';
		this.#byteIdx = 0;
		this.#bitIdx = 0;
	}
}


/**
 * Runs the game.
 */
pikcrossSetup();