Hordes UI Mod

Various UI mods for Hordes.io.

נכון ליום 22-12-2019. ראה הגרסה האחרונה.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Hordes UI Mod
// @version      0.130
// @description  Various UI mods for Hordes.io.
// @author       Sakaiyo
// @match        https://hordes.io/play
// @grant        GM_addStyle
// @namespace https://greatest.deepsurf.us/users/160017
// ==/UserScript==
/**
  * TODO: Implement chat tabs
  * TODO: Implement inventory sorting
  * TODO: (Maybe) Improved healer party frames
  * TODO: Opacity scaler for map
  * TODO: Context menu when right clicking username in chat, party invite and whisper.
  *       Check if it's ok to emulate keypresses before releasing. If not, then maybe copy text to clipboard.
  * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position
  */
(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;

    // The width+height of the maximized chat, so we don't save map size when it's maximized
    const CHAT_MAXIMIZED_SIZE = 692;

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

    let state = {
    	breakingVersion: BREAKING_VERSION,
    	chat: {
    		lvlup: true,
    		gm: true,
    	},
    	windowsPos: {},
    };

    // 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);
		}

		/* Transparent map */
		.svelte-hiyby7 {
			opacity: 0.7;
		}

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

		/* Allows last clicked window to appear above all other windows */
		.js-is-top {
			z-index: 99999 !important;
		}

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

		/* Class that hides chat lines*/
		.js-line-hidden {
			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);
		}
	`);


    const modHelpers = {
    	// Filters all chat based on custom filters
    	filterAllChat: () => {
	    	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]);
		  		});
	    	});
		},
    };

    // MAIN MODS BELOW
    const mods = [
    	// Creates DOM elements for custom chat filters
    	function newChatFilters() {
	    	const $channelselect = document.querySelector('.channelselect');
	    	if (!document.querySelector(`.${CHAT_LVLUP_CLASS}`)) {
		        const $lvlup = createElement({
		        	element: 'small',
		        	class: `btn border black ${CHAT_LVLUP_CLASS} ${state.chat.lvlup ? '' : 'textgrey'}`,
		        	content: 'lvlup'
		        });
		        $channelselect.appendChild($lvlup);
	    	}
	    	if (!document.querySelector(`.${CHAT_GM_CLASS}`)) {
				const $gm = createElement({
		        	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 $chatLvlup = document.querySelector(`.${CHAT_LVLUP_CLASS}`);
    		$chatLvlup.addEventListener('click', () => {
    			state.chat.lvlup = !state.chat.lvlup;
    			$chatLvlup.classList.toggle('textgrey', !state.chat.lvlup);
    			modHelpers.filterAllChat();
    			save({chat: state.chat});
    		});

    		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({chat: state.chat});
    		});
    	},

    	// 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({windowsPos: state.windowsPos});
    			});
    		});
    	},

    	// 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');
    			save({
    				chatWidth: chatWidthStr,
    				chatHeight: chatHeightStr,
    			});
    		});
    		resizeObserverChat.observe($chatContainer);
    	},

    	// Makes map resizable
    	function resizeableMap() {
    		const $map = document.querySelector('.svelte-hiyby7');
    		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) {
    				save({
    					mapWidth: mapWidthStr,
    					mapHeight: mapHeightStr,
    				});
    			}
    		};

	    	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, 200);
    		const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
    		resizeObserverCanvas.observe($canvas);
    	},

    	// 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');
	    		});
    		});
    	},
    ];

    // 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;
    	}

    	$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',
    		];
        	modsToRerun.forEach(modName => {
        		mods.find(mod => mod.name === modName)();
        	});
		});
		rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, })
    }

    // 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) {
		state = {
			...state,
			...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 createElement(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;
	}

	// ...Can't remember why I added this.
	// TODO: Remove this if not using. Can access chat input with it
	function simulateEnterPress() {
		const kbEvent = new KeyboardEvent("keydown", {
		    bubbles: true, cancelable: true, keyCode: 13
		});
		document.body.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);
		};
	}
})();