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
 
Line 2,688: Line 2,688:
  */
  */
function onPageTouchEnd(event) {
function onPageTouchEnd(event) {
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();