Hordes UI Mod

Various UI mods for Hordes.io.

当前为 2019-12-24 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Hordes UI Mod
// @version      0.170
// @description  Various UI mods for Hordes.io.
// @author       Sakaiyo
// @match        https://hordes.io/play
// @grant        GM_addStyle
// @namespace https://greasyfork.org/users/160017
// ==/UserScript==
/**
  * TODO: Implement chat tabs
  * TODO: Implement inventory sorting
  * TODO: (Maybe) Improved healer party frames
  * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position
  * TODO: (Maybe): Add toggleable option to include chat messages to right of party frame
  * TODO: Remove all reliance on svelte- classes, likely breaks with updates
  * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown)
  */
(function() {
    'use strict';

    // If this version is different from the user's stored state,
    // e.g. they have upgraded the version of this script and there are breaking changes,
    // then their stored state will be deleted.
    const BREAKING_VERSION = 1;
    const VERSION = '0.170'; // Should match version in UserScript description

    // The width+height of the maximized chat, so we don't save map size when it's maximized
    // TODO: FIX BUG: This is NOT everyones max size. INSTEAD OF USING A STATIC SIZE, we should detect large instant resizes
    // Should also do this to avoid saving when minimizing menu after maximizing
    const CHAT_MAXIMIZED_SIZE = 692;

    const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';
    const CHAT_GM_CLASS = 'js-chat-gm';

    let state = {
    	breakingVersion: BREAKING_VERSION,
    	chat: {
    		GM: true,
    	},
    	windowsPos: {},
    	blockList: {},
    	friendsList: {},
    	mapOpacity: 70, // e.g. 70 = opacity: 0.7
    };
    // tempState is saved only between page refreshes.
    const tempState = {
    	// The last name clicked in chat
    	chatName: null
    };

    // UPDATING STYLES BELOW - Must be invoked in main function
    GM_addStyle(`
    	/* Transparent chat bg color */
		.frame.svelte-1vrlsr3 {
			background: rgba(0,0,0,0.4);
		}

		/* Our mod's chat message color */
		.textuimod {
			color: #00dd33;
		}

		/* Allows windows to be moved */
		.window {
			position: relative;
		}

		/* Allows last clicked window to appear above all other windows */
		.js-is-top {
			z-index: 9998 !important;
		}
		.panel.context:not(.commandlist) {
			z-index: 9999 !important;
		}
		/* The item icon being dragged in the inventory */
		.container.svelte-120o2pb {
			z-index: 9999 !important;
		}

		/* All purpose hidden class */
		.js-hidden {
			display: none;
		}

		/* Custom chat context menu, invisible by default */
		.js-chat-context-menu {
			display: none;
		}

		.js-chat-context-menu .name {
			color: white;
			padding: 2px 4px;
		}

		/* Allow names in chat to be clicked */
		#chat .name {
			pointer-events: all !important;
		}

		/* Custom chat filter colors */
		.js-chat-gm {
			color: #a6dcd5;
		}

		/* Class that hides chat lines */
		.js-line-hidden,
		.js-line-blocked {
			display: none;
		}

		/* Enable chat & map resize */
		.js-chat-resize {
			resize: both;
			overflow: auto;
		}
		.js-map-resize:hover {
			resize: both;
			overflow: auto;
			direction: rtl;
		}

		/* The browser resize icon */
		*::-webkit-resizer {
	        background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
		    border-radius: 8px;
		    box-shadow: 0 1px 1px rgba(0,0,0,1);
		}
		*::-moz-resizer {
	        background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
		    border-radius: 8px;
		    box-shadow: 0 1px 1px rgba(0,0,0,1);
		}

		.js-map-opacity {
			position: absolute;
			top: 46px;
			right: 12px; 
			z-index: 999;
			width: 100px;
			height: 100px;
			text-align: right;
			display: none;
			pointer-events: all;
		}
		.js-map-opacity:hover {
			display: block;
		}
		.js-map-opacity button {
			border-radius: 10px;
			font-size: 18px;
			padding: 0 5px;
			background: rgba(0,0,0,0.4);
			border: 0;
			color: white;
			font-weight: bold;
			pointer: cursor;
		}
		/* On hover of map, show opacity controls */
		.js-map:hover .js-map-opacity {
			display: block;
		}

		/* Custom css for settings page, duplicates preexisting settings pane grid */
		.uimod-settings {
			display: grid;
			grid-template-columns: 2fr 3fr;
			grid-gap: 8px;
			align-items: center;
			max-height: 390px;
			margin: 0 20px;
			overflow-y: auto;
		}
		/* Friends list CSS, similar to settings but supports 4 columns */
		.uimod-friends {
			display: grid;
			grid-template-columns: 2fr 2fr 2fr 1fr;
			grid-gap: 8px;
			align-items: center;
			max-height: 390px;
			margin: 0 20px;
			overflow-y: auto;
		}
		/* Our custom window, closely mirrors main settings window */
		.uimod-custom-window {
			position: absolute;
			top: 100px;
		    left: 50%;
		    transform: translate(-50%, 0);
		    min-width: 350px;
		    max-width: 600px;
		    width: 90%;
		    height: 80%;
		    min-height: 350px;
		    max-height: 500px;
		    z-index: 9;
		    padding: 0px 10px 5px;
		}
	`);


    const modHelpers = {
    	// Automated chat command helpers
    	// (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev)
    	whisperPlayer: playerName => {
			enterTextIntoChat(`/whisper ${tempState.chatName} `);
    	},
    	partyPlayer: playerName => {
    		enterTextIntoChat(`/partyinvite ${tempState.chatName}`);
			submitChat();
		},

    	// Filters all chat based on custom filters
    	filterAllChat: () => {
    		// Blocked user filter
    		Object.keys(state.blockList).forEach(blockedName => {
    			// Get the `.name` elements from the blocked user, if we haven't already hidden their messages
    			const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
    			// Hide each of their messages
    			$blockedChatNames.forEach($name => {
    				// Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
    				$name.classList.add('js-line-blocked');
    				const $line = $name.parentNode.parentNode.parentNode;
    				// Add the class name to $line so we can visibly hide the entire chat line
    				$line.classList.add('js-line-blocked');
    			});
    		})

    		// Custom channel filter
	    	Object.keys(state.chat).forEach(channel => {
		  		Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
		  			const $line = $textItem.parentNode.parentNode;
		  			$line.classList.toggle('js-line-hidden', !state.chat[channel]);
		  		});
	    	});
		},

		// Makes chat context menu visible and appear under the mouse
		showChatContextMenu: (name, mousePos) => {
			// Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend
			const $contextMenu = document.querySelector('.js-chat-context-menu');
			$contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]);
			$contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]);

			$contextMenu.querySelector('.js-name').textContent = name;
			$contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
		},

		// Close chat context menu if clicking outside of it
		closeChatContextMenu: clickEvent => {
    		const $target = clickEvent.target;
    		// If clicking on name or directly on context menu, don't close it
    		// Still closes if clicking on context menu item
    		if ($target.classList.contains('js-is-context-menu-initd')
    			|| $target.classList.contains('js-chat-context-menu')) {
    			return;
    		}

    		const $contextMenu = document.querySelector('.js-chat-context-menu');
    		$contextMenu.setAttribute('style', 'display: none');
    	},

    	friendPlayer: playerName => {
    		if (state.friendsList[playerName]) {
    			return;
    		}

    		state.friendsList[playerName] = true;
    		modHelpers.addChatMessage(`${playerName} has been added to your friends list.`);
    		save();
    	},

    	unfriendPlayer: playerName => {
    		if (!state.friendsList[playerName]) {
    			return;
    		}

    		delete state.friendsList[playerName];
    		modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`);
    		save();
    	},

    	// Adds player to block list, to be filtered out of chat
    	blockPlayer: playerName => {
    		if (state.blockList[playerName]) {
    			return;
    		}

    		state.blockList[playerName] = true;
    		modHelpers.filterAllChat();
    		modHelpers.addChatMessage(`${playerName} has been blocked.`)
    		save();
    	},

    	// Removes player from block list and makes their messages visible again
    	unblockPlayer: playerName => {
    		delete state.blockList[playerName];
    		modHelpers.addChatMessage(`${playerName} has been unblocked.`);
    		save();

    		// Make messages visible again
    		const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
    		$chatNames.forEach($name => {
    			$name.classList.remove('js-line-blocked');
    			const $line = $name.parentNode.parentNode.parentNode;
    			$line.classList.remove('js-line-blocked');
    		});
    	},

    	// Pushes message to chat
    	// TODO: The margins for the message are off slightly compared to other messages - why?
    	addChatMessage: text => {
    		const newMessageHTML = `
			<div class="linewrap svelte-1vrlsr3">
				<span class="time svelte-1vrlsr3">00.00</span>
				<span class="textuimod content svelte-1vrlsr3">
				<span class="capitalize channel svelte-1vrlsr3">UIMod</span>
				</span>
				<span class="svelte-1vrlsr3">${text}</span>
			</div>
    		`;

    		const element = makeElement({
    			element: 'article',
    			class: 'line svelte-1vrlsr3',
    			content: newMessageHTML});
    		document.querySelector('#chat').appendChild(element);
    	},
    };

    // MAIN MODS BELOW
    const mods = [
    	// Creates DOM elements for custom chat filters
    	function newChatFilters() {
	    	const $channelselect = document.querySelector('.channelselect');
	    	if (!document.querySelector(`.${CHAT_GM_CLASS}`)) {
				const $gm = makeElement({
		        	element: 'small',
		        	class: `btn border black ${CHAT_GM_CLASS} ${state.chat.GM ? '' : 'textgrey'}`,
		        	content: 'GM'
		        });
		        $channelselect.appendChild($gm);
		    }
	    },

    	// Wire up new chat buttons to toggle in state+ui
    	function newChatFilterButtons() {
    		const $chatGM = document.querySelector(`.${CHAT_GM_CLASS}`);
    		$chatGM.addEventListener('click', () => {
    			state.chat.GM = !state.chat.GM;
    			$chatGM.classList.toggle('textgrey', !state.chat.GM);
    			modHelpers.filterAllChat();
    			save();
    		});
    	},

    	// Filter out chat in UI based on chat buttons state
    	function filterChatObserver() {
		    const chatObserver = new MutationObserver(modHelpers.filterAllChat);
			chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
    	},

    	// Drag all windows by their header
    	function draggableUIWindows() {
    		Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
    			$window.classList.add('js-can-move');
				dragElement($window, $window.querySelector('.titleframe'));
    		});
    	},

    	// Save dragged UI windows position to state
    	function saveDraggedUIWindows() {
    		Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
    			$window.classList.add('js-window-is-saving');
    			const $draggableTarget = $window.querySelector('.titleframe');
    			const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
    			$draggableTarget.addEventListener('mouseup', () => {
    				state.windowsPos[windowName] = $window.getAttribute('style');
    				save();
    			});
    		});
    	},

    	// Loads draggable UI windows position from state
    	function loadDraggedUIWindowsPositions() {
    		Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
    			$window.classList.add('js-has-loaded-pos');
				const windowName = $window.querySelector('[name="title"]').textContent;
				const pos = state.windowsPos[windowName];
				if (pos) {
					$window.setAttribute('style', pos);
				}
    		});
    	},

    	// Makes chat resizable
    	function resizableChat() {
    		// Add the appropriate classes
    		const $chatContainer = document.querySelector('#chat').parentNode;
    		$chatContainer.classList.add('js-chat-resize');

    		// Load initial chat and map size
    		if (state.chatWidth && state.chatHeight) {
	    		$chatContainer.style.width = state.chatWidth;
	    		$chatContainer.style.height = state.chatHeight;
	    	}

    		// Save chat size on resize
    		const resizeObserverChat = new ResizeObserver(() => {
    			const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
    			const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
    			state.chatWidth = chatWidthStr;
    			state.chatHeight = chatHeightStr;
    			save();
    		});
    		resizeObserverChat.observe($chatContainer);
    	},

    	// Makes map resizable
    	function resizeableMap() {
    		const $map = document.querySelector('.container canvas').parentNode;
    		const $canvas = $map.querySelector('canvas');
    		$map.classList.add('js-map-resize');

    		const onMapResize = () => {
    			// Get real values of map height/width, excluding padding/margin/etc
    			const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
    			const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
    			const mapWidth = Number(mapWidthStr.slice(0, -2));
    			const mapHeight = Number(mapHeightStr.slice(0, -2));

    			// If height/width are 0 or unset, don't resize canvas
    			if (!mapWidth || !mapHeight) {
    				return;
    			}

    			if ($canvas.width !== mapWidth) {
    				$canvas.width = mapWidth;
    			}

    			if ($canvas.height !== mapHeight) {
    				$canvas.height = mapHeight;
    			}

    			// Save map size on resize, unless map has been maximized by user
    			if (mapWidth !== CHAT_MAXIMIZED_SIZE && mapHeight !== CHAT_MAXIMIZED_SIZE) {
    				state.mapWidth = mapWidthStr;
    				state.mapHeight = mapHeightStr;
    				save();
    			}
    		};

	    	if (state.mapWidth && state.mapHeight) {
	    		$map.style.width = state.mapWidth;
	    		$map.style.height = state.mapHeight;
	    		onMapResize(); // Update canvas size on initial load of saved map size
	    	}

    		// On resize of map, resize canvas to match
    		const resizeObserverMap = new ResizeObserver(onMapResize);
    		resizeObserverMap.observe($map);

    		// We need to observe canvas resizes to tell when the user presses M to open the big map
    		// At that point, we resize the map to match the canvas
    		const triggerResize = () => {
    			// Get real values of map height/width, excluding padding/margin/etc
    			const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
    			const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
    			const mapWidth = Number(mapWidthStr.slice(0, -2));
    			const mapHeight = Number(mapHeightStr.slice(0, -2));

    			// If height/width are 0 or unset, we don't care about resizing yet
    			if (!mapWidth || !mapHeight) {
    				return;
    			}

    			if ($canvas.width !== mapWidth) {
    				$map.style.width = `${$canvas.width}px`;
    			}

    			if ($canvas.height !== mapHeight) {
    				$map.style.height = `${$canvas.height}px`;
    			}
    		};

    		// We debounce the canvas resize, so it doesn't resize every single
    		// pixel you move when resizing the DOM. If this were to happen,
    		// resizing would constantly be interrupted. You'd have to resize a tiny bit,
    		// lift left click, left click again to resize a tiny bit more, etc.
    		// Resizing is smooth when we debounce this canvas.
	    	const debouncedTriggerResize = debounce(triggerResize, 50);
    		const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
    		resizeObserverCanvas.observe($canvas);
    	},

    	function mapOpacityControls() {
    		const $map = document.querySelector('.container canvas');
    		if (!$map.parentNode.classList.contains('js-map')) {
    			$map.parentNode.classList.add('js-map');
    		}
    		const $mapContainer = document.querySelector('.js-map');

    		// On load, update map opacity to match state
    		// We modify the opacity of the canvas and the background color alpha of the parent container
    		// We do this to allow our opacity buttons to be visible on hover with 100% opacity
    		// (A surprisingly difficult enough task to require this implementation)
    		const updateMapOpacity = () => {
    			$map.setAttribute('style', `opacity: ${String(state.mapOpacity / 100)}`);
				const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color');
				// Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript
				const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${state.mapOpacity / 100})`);
				$mapContainer.setAttribute('style', `background-color: ${newBgColor}`);
    		};
    		updateMapOpacity();

    		const $opacityButtons = makeElement({
    			element: 'div',
    			class: 'js-map-opacity',
    			content: `<button class="js-map-opacity-add">+</button><button class="js-map-opacity-minus">-</button>`,
    		});

    		// Add it right before the map container div
    		$map.parentNode.insertBefore($opacityButtons, $map);

    		const $addBtn = document.querySelector('.js-map-opacity-add');
    		const $minusBtn = document.querySelector('.js-map-opacity-minus');

    		// Hide the buttons if map opacity is maxed/minimum
    		if (state.mapOpacity === 100) {
    			$addBtn.setAttribute('style', 'visibility: hidden');
    		}
    		if (state.mapOpacity === 0) {
    			$minusBtn.setAttribute('style', 'visibility: hidden');
    		}

    		// Wire it up
    		$addBtn.addEventListener('click', clickEvent => {
    			// Update opacity
    			state.mapOpacity += 10;
    			save();
				updateMapOpacity();

				// Hide this button if the opacity is max
				if (state.mapOpacity === 100) {
					const $btn = clickEvent.target;
					// We use visibility to hide the button but keep its position in the UI
					$btn.setAttribute('style', 'visibility: hidden');
				}
				// If map opacity is not the lowest, then make minus button visible
				if (state.mapOpacity !== 0) {
					$minusBtn.setAttribute('style', 'visibility: visible');
				} 
    			save();
    		});

    		$minusBtn.addEventListener('click', clickEvent => {
    			// Update opacity
    			state.mapOpacity -= 10;
    			save();
				updateMapOpacity();

				// Hide this button if the opacity is lowest
				if (state.mapOpacity === 0) {
					const $btn = clickEvent.target;
					$btn.setAttribute('style', 'visibility: hidden');
				}
				// If map opacity is not the max, then make add button visible
				if (state.mapOpacity !== 100) {
					$addBtn.setAttribute('style', 'visibility: visible');
				}
    		});
    	},

    	// The last clicked UI window displays above all other UI windows
    	// This is useful when, for example, your inventory is near the market window,
    	// and you want the window and the tooltips to display above the market window.
    	function selectedWindowIsTop() {
    		Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
    			$window.classList.add('js-is-top-initd');

    			$window.addEventListener('mousedown', () => {
	    			// First, make the other is-top window not is-top
	    			const $otherWindowContainer = document.querySelector('.js-is-top');
	    			if ($otherWindowContainer) {
	    				$otherWindowContainer.classList.remove('js-is-top');
	    			}

	    			// Then, make our window's container (the z-index container) is-top
	    			$window.parentNode.classList.add('js-is-top');
	    		});
    		});
    	},

    	// The F icon and the UI that appears when you click it
    	function customFriendsList() {
    		var friendsIconElement = makeElement({
    			element: 'div',
    			class: 'btn border black js-friends-list-icon',
    			content: 'F',
    		});
    		// Add the icon to the right of Elixir icon
    		const $elixirIcon = document.querySelector('#sysgem');
    		$elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling);

    		// Create the friends list UI
    		document.querySelector('.js-friends-list-icon').addEventListener('click', () => {
    			if (document.querySelector('.js-friends-list')) {
    				// Don't open the friends list twice.
    				return;
    			}
    			let friendsListHTML = '';
    			Object.keys(state.friendsList).sort().forEach(friendName => {
    				friendsListHTML += `
    					<div data-player-name="${friendName}">${friendName}</div>
    					<div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div>
    					<div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div>
    					<div class="btn orange js-unfriend-player" data-player-name="${friendName}">Remove</div>
					`;
    			});

    			const customFriendsWindowHTML = `
    				<h3 class="textprimary">Friends list</h3>
    				<div class="uimod-friends">${friendsListHTML}</div>
    				<p></p>
    				<div class="btn purp js-close-custom-friends-list">Close</div>
    			`;

				const $customFriendsList = makeElement({
    				element: 'div',
    				class: 'menu panel-black js-friends-list uimod-custom-window',
    				content: customFriendsWindowHTML,
    			});
    			document.body.appendChild($customFriendsList);

    			// Wire up the buttons
    			Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => {
    				$button.addEventListener('click', clickEvent => {
    					const name = clickEvent.target.getAttribute('data-player-name');
    					modHelpers.whisperPlayer(name);
    				});
    			});
    			Array.from(document.querySelectorAll('.js-party-player')).forEach($button => {
    				$button.addEventListener('click', clickEvent => {
    					const name = clickEvent.target.getAttribute('data-player-name');
    					modHelpers.partyPlayer(name);
    				});
    			});
    			Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => {
    				$button.addEventListener('click', clickEvent => {
    					const name = clickEvent.target.getAttribute('data-player-name');
    					modHelpers.unfriendPlayer(name);

    					// Remove the blocked player from the list
    					Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => {
    						$element.parentNode.removeChild($element);
    					});
    				});
    			});

    			// The close button for our custom UI
    			document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => {
    				const $friendsListWindow = document.querySelector('.js-friends-list');
    				$friendsListWindow.parentNode.removeChild($friendsListWindow);
    			});
    		});
    	},

    	// Custom settings UI, currently just Blocked players
    	function customSettings() {
    		const $settings = document.querySelector('.divide:not(.js-settings-initd)');
    		if (!$settings) {
    			return;
    		}

    		$settings.classList.add('js-settings-initd');
    		const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
    		$settingsChoiceList.appendChild(makeElement({
    			element: 'div',
    			class: 'choice js-blocked-players',
    			content: 'Blocked players',
    		}));

    		// Upon click, we display our custom settings window UI
    		document.querySelector('.js-blocked-players').addEventListener('click', () => {
    			let blockedPlayersHTML = '';
    			Object.keys(state.blockList).sort().forEach(blockedName => {
    				blockedPlayersHTML += `
    					<div data-player-name="${blockedName}">${blockedName}</div>
    					<div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
					`;
    			});

    			const customSettingsHTML = `
    				<h3 class="textprimary">Blocked players</h3>
    				<div class="settings uimod-settings">${blockedPlayersHTML}</div>
    				<p></p>
    				<div class="btn purp js-close-custom-settings">Close</div>
    			`;

    			const $customSettings = makeElement({
    				element: 'div',
    				class: 'menu panel-black js-custom-settings uimod-custom-window',
    				content: customSettingsHTML,
    			});
    			document.body.appendChild($customSettings);

    			// Wire up all the unblock buttons
    			Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
    				$button.addEventListener('click', clickEvent => {
    					const name = clickEvent.target.getAttribute('data-player-name');
    					modHelpers.unblockPlayer(name);

    					// Remove the blocked player from the list
    					Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => {
    						$element.parentNode.removeChild($element);
    					});
    				});
    			});
    			// And the close button for our custom UI
    			document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
    				const $customSettingsWindow = document.querySelector('.js-custom-settings');
    				$customSettingsWindow.parentNode.removeChild($customSettingsWindow);
    			});
    		});
    	},

    	// This creates the initial chat context menu (which starts as hidden)
    	function createChatContextMenu() {
    		if (document.querySelector('.js-chat-context-menu')) {
    			return;
    		}

    		let contextMenuHTML = `
    			<div class="js-name">...</div>
				<div class="choice" name="party">Party invite</div>
				<div class="choice" name="whisper">Whisper</div>
				<div class="choice" name="friend">Friend</div>
				<div class="choice" name="unfriend">Unfriend</div>
				<div class="choice" name="block">Block</div>
			`
    		document.body.appendChild(makeElement({
    			element: 'div',
    			class: 'panel context border grey js-chat-context-menu',
    			content: contextMenuHTML,
    		}));

    		const $chatContextMenu = document.querySelector('.js-chat-context-menu');
    		$chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
				modHelpers.partyPlayer(tempState.chatName);
    		});
    		$chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
    			modHelpers.whisperPlayer(tempState.chatName);
    		});
    		$chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => {
    			modHelpers.friendPlayer(tempState.chatName);
    		});
    		$chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => {
    			modHelpers.unfriendPlayer(tempState.chatName);
    		});
    		$chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
    			modHelpers.blockPlayer(tempState.chatName);
    		});
    	},

    	// This opens a context menu when you click a user's name in chat
    	function chatContextMenu() {
    		Array.from(document.querySelectorAll('.name:not(.js-is-context-menu-initd)')).forEach($name => {
    			$name.classList.add('js-is-context-menu-initd');
    			// Add name to element so we can target it in CSS when filtering chat for block list
    			$name.setAttribute('data-chat-name', $name.textContent);

    			const showContextMenu = clickEvent => {
    				// TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
    				tempState.chatName = $name.textContent;
    				modHelpers.showChatContextMenu($name.textContent, {x: clickEvent.pageX, y: clickEvent.pageY});
    			};
    			$name.addEventListener('click', showContextMenu);
    		});
    	},
    ];

    // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
    function initialize() {
    	// If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
    	// We check for that and reinitialize the mod if that happens
    	const $layout = document.querySelector('.layout');
    	if ($layout.classList.contains('uimod-initd')) {
    		return;
    	}

    	modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);

    	$layout.classList.add('uimod-initd')
		load();
        mods.forEach(mod => mod());

        // Continuously re-run specific mods methods that need to be executed on UI change
		const rerunObserver = new MutationObserver(() => {
			// If new window appears, e.g. even if window is closed and reopened, we need to rewire it
        	// Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
        	// 		     But some windows only exist in the DOM when open, e.g. Interaction
        	const modsToRerun = [
        		'saveDraggedUIWindows',
        		'draggableUIWindows',
        		'loadDraggedUIWindowsPositions',
        		'selectedWindowIsTop',
        		'customSettings',
    		];
        	modsToRerun.forEach(modName => {
        		mods.find(mod => mod.name === modName)();
        	});
		});
		rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });

		// Rerun only on chat
		const chatRerunObserver = new MutationObserver(() => {
			mods.find(mod => mod.name === 'chatContextMenu')();
			modHelpers.filterAllChat();

		});
		chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });

		// Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
		if (!document.body.classList.contains('js-uimod-initd')) {
			document.body.classList.add('js-uimod-initd');

			// Close chat context menu when clicking outside of it
			document.body.addEventListener('click', modHelpers.closeChatContextMenu);
		}
    }

    // Initialize mods once UI DOM has loaded
    const pageObserver = new MutationObserver(() => {
	    const isUiLoaded = !!document.querySelector('.layout');
	    if (isUiLoaded) {
	    	initialize();
	    }
	});
	pageObserver.observe(document.body, { attributes: true, childList: true })

	// UTIL METHODS
	// Save to in-memory state and localStorage to retain on refresh
	function save(items) {
		localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
	}

	// Load localStorage state if it exists
	// NOTE: If user is trying to load unsupported version of stored state,
	//       e.g. they just upgraded to breaking version, then we delete their stored state
	function load() {
		const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
		if (storedStateJson) {
			const storedState = JSON.parse(storedStateJson);
			if (storedState.breakingVersion !== BREAKING_VERSION) {
				localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
				return;
			}
			state = {
				...state,
				...storedState,
			};
		}
	}

	// Nicer impl to create elements in one method call
	function makeElement(args) {
		const $node = document.createElement(args.element);
		if (args.class) { $node.className = args.class; }
		if (args.content) { $node.innerHTML = args.content; }
		if (args.src) { $node.src = args.src; }
		return $node;
	}

	function enterTextIntoChat(text) {
		// Open chat input
		const enterEvent = new KeyboardEvent('keydown', {
		    bubbles: true, cancelable: true, keyCode: 13
		});
		document.body.dispatchEvent(enterEvent);

		// Place text into chat
		const $input = document.querySelector('#chatinput input');
		$input.value = text;

		// Get chat input to recognize slash commands and change the channel
		// by triggering the `input` event.
		// (Did some debugging to figure out the channel only changes when the
		//  svelte `input` event listener exists.)
		const inputEvent = new KeyboardEvent('input', {
			bubbles: true, cancelable: true
		})
		$input.dispatchEvent(inputEvent);
	}

	function submitChat() {
		const $input = document.querySelector('#chatinput input');
		const kbEvent = new KeyboardEvent('keydown', {
		    bubbles: true, cancelable: true, keyCode: 13
		});
		$input.dispatchEvent(kbEvent);
	}

	// Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
	// $draggedElement is the item that will be dragged.
	// $dragTrigger is the element that must be held down to drag $draggedElement
	function dragElement($draggedElement, $dragTrigger) {
		let offset = [0,0];
		let isDown = false;
		$dragTrigger.addEventListener('mousedown', function(e) {
		    isDown = true;
		    offset = [
		        $draggedElement.offsetLeft - e.clientX,
		        $draggedElement.offsetTop - e.clientY
		    ];
		}, true);
		document.addEventListener('mouseup', function() {
		    isDown = false;
		}, true);

		document.addEventListener('mousemove', function(e) {
		    event.preventDefault();
		    if (isDown) {
		        $draggedElement.style.left = (e.clientX + offset[0]) + 'px';
		        $draggedElement.style.top = (e.clientY + offset[1]) + 'px';
		    }
		}, true);
	}

	// Credit: David Walsh
	function debounce(func, wait, immediate) {
		var timeout;
		return function() {
			var context = this, args = arguments;
			var later = function() {
				timeout = null;
				if (!immediate) func.apply(context, args);
			};
			var callNow = immediate && !timeout;
			clearTimeout(timeout);
			timeout = setTimeout(later, wait);
			if (callNow) func.apply(context, args);
		};
	}
})();