Hordes UI Mod

Various UI mods for Hordes.io.

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

  1. // ==UserScript==
  2. // @name Hordes UI Mod
  3. // @version 0.170
  4. // @description Various UI mods for Hordes.io.
  5. // @author Sakaiyo
  6. // @match https://hordes.io/play
  7. // @grant GM_addStyle
  8. // @namespace https://greasyfork.org/users/160017
  9. // ==/UserScript==
  10. /**
  11. * TODO: Implement chat tabs
  12. * TODO: Implement inventory sorting
  13. * TODO: (Maybe) Improved healer party frames
  14. * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position
  15. * TODO: (Maybe): Add toggleable option to include chat messages to right of party frame
  16. * TODO: Remove all reliance on svelte- classes, likely breaks with updates
  17. * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown)
  18. */
  19. (function() {
  20. 'use strict';
  21.  
  22. // If this version is different from the user's stored state,
  23. // e.g. they have upgraded the version of this script and there are breaking changes,
  24. // then their stored state will be deleted.
  25. const BREAKING_VERSION = 1;
  26. const VERSION = '0.170'; // Should match version in UserScript description
  27.  
  28. // The width+height of the maximized chat, so we don't save map size when it's maximized
  29. // TODO: FIX BUG: This is NOT everyones max size. INSTEAD OF USING A STATIC SIZE, we should detect large instant resizes
  30. // Should also do this to avoid saving when minimizing menu after maximizing
  31. const CHAT_MAXIMIZED_SIZE = 692;
  32.  
  33. const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';
  34. const CHAT_GM_CLASS = 'js-chat-gm';
  35.  
  36. let state = {
  37. breakingVersion: BREAKING_VERSION,
  38. chat: {
  39. GM: true,
  40. },
  41. windowsPos: {},
  42. blockList: {},
  43. friendsList: {},
  44. mapOpacity: 70, // e.g. 70 = opacity: 0.7
  45. };
  46. // tempState is saved only between page refreshes.
  47. const tempState = {
  48. // The last name clicked in chat
  49. chatName: null
  50. };
  51.  
  52. // UPDATING STYLES BELOW - Must be invoked in main function
  53. GM_addStyle(`
  54. /* Transparent chat bg color */
  55. .frame.svelte-1vrlsr3 {
  56. background: rgba(0,0,0,0.4);
  57. }
  58.  
  59. /* Our mod's chat message color */
  60. .textuimod {
  61. color: #00dd33;
  62. }
  63.  
  64. /* Allows windows to be moved */
  65. .window {
  66. position: relative;
  67. }
  68.  
  69. /* Allows last clicked window to appear above all other windows */
  70. .js-is-top {
  71. z-index: 9998 !important;
  72. }
  73. .panel.context:not(.commandlist) {
  74. z-index: 9999 !important;
  75. }
  76. /* The item icon being dragged in the inventory */
  77. .container.svelte-120o2pb {
  78. z-index: 9999 !important;
  79. }
  80.  
  81. /* All purpose hidden class */
  82. .js-hidden {
  83. display: none;
  84. }
  85.  
  86. /* Custom chat context menu, invisible by default */
  87. .js-chat-context-menu {
  88. display: none;
  89. }
  90.  
  91. .js-chat-context-menu .name {
  92. color: white;
  93. padding: 2px 4px;
  94. }
  95.  
  96. /* Allow names in chat to be clicked */
  97. #chat .name {
  98. pointer-events: all !important;
  99. }
  100.  
  101. /* Custom chat filter colors */
  102. .js-chat-gm {
  103. color: #a6dcd5;
  104. }
  105.  
  106. /* Class that hides chat lines */
  107. .js-line-hidden,
  108. .js-line-blocked {
  109. display: none;
  110. }
  111.  
  112. /* Enable chat & map resize */
  113. .js-chat-resize {
  114. resize: both;
  115. overflow: auto;
  116. }
  117. .js-map-resize:hover {
  118. resize: both;
  119. overflow: auto;
  120. direction: rtl;
  121. }
  122.  
  123. /* The browser resize icon */
  124. *::-webkit-resizer {
  125. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  126. border-radius: 8px;
  127. box-shadow: 0 1px 1px rgba(0,0,0,1);
  128. }
  129. *::-moz-resizer {
  130. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  131. border-radius: 8px;
  132. box-shadow: 0 1px 1px rgba(0,0,0,1);
  133. }
  134.  
  135. .js-map-opacity {
  136. position: absolute;
  137. top: 46px;
  138. right: 12px;
  139. z-index: 999;
  140. width: 100px;
  141. height: 100px;
  142. text-align: right;
  143. display: none;
  144. pointer-events: all;
  145. }
  146. .js-map-opacity:hover {
  147. display: block;
  148. }
  149. .js-map-opacity button {
  150. border-radius: 10px;
  151. font-size: 18px;
  152. padding: 0 5px;
  153. background: rgba(0,0,0,0.4);
  154. border: 0;
  155. color: white;
  156. font-weight: bold;
  157. pointer: cursor;
  158. }
  159. /* On hover of map, show opacity controls */
  160. .js-map:hover .js-map-opacity {
  161. display: block;
  162. }
  163.  
  164. /* Custom css for settings page, duplicates preexisting settings pane grid */
  165. .uimod-settings {
  166. display: grid;
  167. grid-template-columns: 2fr 3fr;
  168. grid-gap: 8px;
  169. align-items: center;
  170. max-height: 390px;
  171. margin: 0 20px;
  172. overflow-y: auto;
  173. }
  174. /* Friends list CSS, similar to settings but supports 4 columns */
  175. .uimod-friends {
  176. display: grid;
  177. grid-template-columns: 2fr 2fr 2fr 1fr;
  178. grid-gap: 8px;
  179. align-items: center;
  180. max-height: 390px;
  181. margin: 0 20px;
  182. overflow-y: auto;
  183. }
  184. /* Our custom window, closely mirrors main settings window */
  185. .uimod-custom-window {
  186. position: absolute;
  187. top: 100px;
  188. left: 50%;
  189. transform: translate(-50%, 0);
  190. min-width: 350px;
  191. max-width: 600px;
  192. width: 90%;
  193. height: 80%;
  194. min-height: 350px;
  195. max-height: 500px;
  196. z-index: 9;
  197. padding: 0px 10px 5px;
  198. }
  199. `);
  200.  
  201.  
  202. const modHelpers = {
  203. // Automated chat command helpers
  204. // (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev)
  205. whisperPlayer: playerName => {
  206. enterTextIntoChat(`/whisper ${tempState.chatName} `);
  207. },
  208. partyPlayer: playerName => {
  209. enterTextIntoChat(`/partyinvite ${tempState.chatName}`);
  210. submitChat();
  211. },
  212.  
  213. // Filters all chat based on custom filters
  214. filterAllChat: () => {
  215. // Blocked user filter
  216. Object.keys(state.blockList).forEach(blockedName => {
  217. // Get the `.name` elements from the blocked user, if we haven't already hidden their messages
  218. const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
  219. // Hide each of their messages
  220. $blockedChatNames.forEach($name => {
  221. // Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
  222. $name.classList.add('js-line-blocked');
  223. const $line = $name.parentNode.parentNode.parentNode;
  224. // Add the class name to $line so we can visibly hide the entire chat line
  225. $line.classList.add('js-line-blocked');
  226. });
  227. })
  228.  
  229. // Custom channel filter
  230. Object.keys(state.chat).forEach(channel => {
  231. Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
  232. const $line = $textItem.parentNode.parentNode;
  233. $line.classList.toggle('js-line-hidden', !state.chat[channel]);
  234. });
  235. });
  236. },
  237.  
  238. // Makes chat context menu visible and appear under the mouse
  239. showChatContextMenu: (name, mousePos) => {
  240. // Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend
  241. const $contextMenu = document.querySelector('.js-chat-context-menu');
  242. $contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]);
  243. $contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]);
  244.  
  245. $contextMenu.querySelector('.js-name').textContent = name;
  246. $contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
  247. },
  248.  
  249. // Close chat context menu if clicking outside of it
  250. closeChatContextMenu: clickEvent => {
  251. const $target = clickEvent.target;
  252. // If clicking on name or directly on context menu, don't close it
  253. // Still closes if clicking on context menu item
  254. if ($target.classList.contains('js-is-context-menu-initd')
  255. || $target.classList.contains('js-chat-context-menu')) {
  256. return;
  257. }
  258.  
  259. const $contextMenu = document.querySelector('.js-chat-context-menu');
  260. $contextMenu.setAttribute('style', 'display: none');
  261. },
  262.  
  263. friendPlayer: playerName => {
  264. if (state.friendsList[playerName]) {
  265. return;
  266. }
  267.  
  268. state.friendsList[playerName] = true;
  269. modHelpers.addChatMessage(`${playerName} has been added to your friends list.`);
  270. save();
  271. },
  272.  
  273. unfriendPlayer: playerName => {
  274. if (!state.friendsList[playerName]) {
  275. return;
  276. }
  277.  
  278. delete state.friendsList[playerName];
  279. modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`);
  280. save();
  281. },
  282.  
  283. // Adds player to block list, to be filtered out of chat
  284. blockPlayer: playerName => {
  285. if (state.blockList[playerName]) {
  286. return;
  287. }
  288.  
  289. state.blockList[playerName] = true;
  290. modHelpers.filterAllChat();
  291. modHelpers.addChatMessage(`${playerName} has been blocked.`)
  292. save();
  293. },
  294.  
  295. // Removes player from block list and makes their messages visible again
  296. unblockPlayer: playerName => {
  297. delete state.blockList[playerName];
  298. modHelpers.addChatMessage(`${playerName} has been unblocked.`);
  299. save();
  300.  
  301. // Make messages visible again
  302. const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
  303. $chatNames.forEach($name => {
  304. $name.classList.remove('js-line-blocked');
  305. const $line = $name.parentNode.parentNode.parentNode;
  306. $line.classList.remove('js-line-blocked');
  307. });
  308. },
  309.  
  310. // Pushes message to chat
  311. // TODO: The margins for the message are off slightly compared to other messages - why?
  312. addChatMessage: text => {
  313. const newMessageHTML = `
  314. <div class="linewrap svelte-1vrlsr3">
  315. <span class="time svelte-1vrlsr3">00.00</span>
  316. <span class="textuimod content svelte-1vrlsr3">
  317. <span class="capitalize channel svelte-1vrlsr3">UIMod</span>
  318. </span>
  319. <span class="svelte-1vrlsr3">${text}</span>
  320. </div>
  321. `;
  322.  
  323. const element = makeElement({
  324. element: 'article',
  325. class: 'line svelte-1vrlsr3',
  326. content: newMessageHTML});
  327. document.querySelector('#chat').appendChild(element);
  328. },
  329. };
  330.  
  331. // MAIN MODS BELOW
  332. const mods = [
  333. // Creates DOM elements for custom chat filters
  334. function newChatFilters() {
  335. const $channelselect = document.querySelector('.channelselect');
  336. if (!document.querySelector(`.${CHAT_GM_CLASS}`)) {
  337. const $gm = makeElement({
  338. element: 'small',
  339. class: `btn border black ${CHAT_GM_CLASS} ${state.chat.GM ? '' : 'textgrey'}`,
  340. content: 'GM'
  341. });
  342. $channelselect.appendChild($gm);
  343. }
  344. },
  345.  
  346. // Wire up new chat buttons to toggle in state+ui
  347. function newChatFilterButtons() {
  348. const $chatGM = document.querySelector(`.${CHAT_GM_CLASS}`);
  349. $chatGM.addEventListener('click', () => {
  350. state.chat.GM = !state.chat.GM;
  351. $chatGM.classList.toggle('textgrey', !state.chat.GM);
  352. modHelpers.filterAllChat();
  353. save();
  354. });
  355. },
  356.  
  357. // Filter out chat in UI based on chat buttons state
  358. function filterChatObserver() {
  359. const chatObserver = new MutationObserver(modHelpers.filterAllChat);
  360. chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
  361. },
  362.  
  363. // Drag all windows by their header
  364. function draggableUIWindows() {
  365. Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
  366. $window.classList.add('js-can-move');
  367. dragElement($window, $window.querySelector('.titleframe'));
  368. });
  369. },
  370.  
  371. // Save dragged UI windows position to state
  372. function saveDraggedUIWindows() {
  373. Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
  374. $window.classList.add('js-window-is-saving');
  375. const $draggableTarget = $window.querySelector('.titleframe');
  376. const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
  377. $draggableTarget.addEventListener('mouseup', () => {
  378. state.windowsPos[windowName] = $window.getAttribute('style');
  379. save();
  380. });
  381. });
  382. },
  383.  
  384. // Loads draggable UI windows position from state
  385. function loadDraggedUIWindowsPositions() {
  386. Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
  387. $window.classList.add('js-has-loaded-pos');
  388. const windowName = $window.querySelector('[name="title"]').textContent;
  389. const pos = state.windowsPos[windowName];
  390. if (pos) {
  391. $window.setAttribute('style', pos);
  392. }
  393. });
  394. },
  395.  
  396. // Makes chat resizable
  397. function resizableChat() {
  398. // Add the appropriate classes
  399. const $chatContainer = document.querySelector('#chat').parentNode;
  400. $chatContainer.classList.add('js-chat-resize');
  401.  
  402. // Load initial chat and map size
  403. if (state.chatWidth && state.chatHeight) {
  404. $chatContainer.style.width = state.chatWidth;
  405. $chatContainer.style.height = state.chatHeight;
  406. }
  407.  
  408. // Save chat size on resize
  409. const resizeObserverChat = new ResizeObserver(() => {
  410. const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
  411. const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
  412. state.chatWidth = chatWidthStr;
  413. state.chatHeight = chatHeightStr;
  414. save();
  415. });
  416. resizeObserverChat.observe($chatContainer);
  417. },
  418.  
  419. // Makes map resizable
  420. function resizeableMap() {
  421. const $map = document.querySelector('.container canvas').parentNode;
  422. const $canvas = $map.querySelector('canvas');
  423. $map.classList.add('js-map-resize');
  424.  
  425. const onMapResize = () => {
  426. // Get real values of map height/width, excluding padding/margin/etc
  427. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  428. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  429. const mapWidth = Number(mapWidthStr.slice(0, -2));
  430. const mapHeight = Number(mapHeightStr.slice(0, -2));
  431.  
  432. // If height/width are 0 or unset, don't resize canvas
  433. if (!mapWidth || !mapHeight) {
  434. return;
  435. }
  436.  
  437. if ($canvas.width !== mapWidth) {
  438. $canvas.width = mapWidth;
  439. }
  440.  
  441. if ($canvas.height !== mapHeight) {
  442. $canvas.height = mapHeight;
  443. }
  444.  
  445. // Save map size on resize, unless map has been maximized by user
  446. if (mapWidth !== CHAT_MAXIMIZED_SIZE && mapHeight !== CHAT_MAXIMIZED_SIZE) {
  447. state.mapWidth = mapWidthStr;
  448. state.mapHeight = mapHeightStr;
  449. save();
  450. }
  451. };
  452.  
  453. if (state.mapWidth && state.mapHeight) {
  454. $map.style.width = state.mapWidth;
  455. $map.style.height = state.mapHeight;
  456. onMapResize(); // Update canvas size on initial load of saved map size
  457. }
  458.  
  459. // On resize of map, resize canvas to match
  460. const resizeObserverMap = new ResizeObserver(onMapResize);
  461. resizeObserverMap.observe($map);
  462.  
  463. // We need to observe canvas resizes to tell when the user presses M to open the big map
  464. // At that point, we resize the map to match the canvas
  465. const triggerResize = () => {
  466. // Get real values of map height/width, excluding padding/margin/etc
  467. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  468. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  469. const mapWidth = Number(mapWidthStr.slice(0, -2));
  470. const mapHeight = Number(mapHeightStr.slice(0, -2));
  471.  
  472. // If height/width are 0 or unset, we don't care about resizing yet
  473. if (!mapWidth || !mapHeight) {
  474. return;
  475. }
  476.  
  477. if ($canvas.width !== mapWidth) {
  478. $map.style.width = `${$canvas.width}px`;
  479. }
  480.  
  481. if ($canvas.height !== mapHeight) {
  482. $map.style.height = `${$canvas.height}px`;
  483. }
  484. };
  485.  
  486. // We debounce the canvas resize, so it doesn't resize every single
  487. // pixel you move when resizing the DOM. If this were to happen,
  488. // resizing would constantly be interrupted. You'd have to resize a tiny bit,
  489. // lift left click, left click again to resize a tiny bit more, etc.
  490. // Resizing is smooth when we debounce this canvas.
  491. const debouncedTriggerResize = debounce(triggerResize, 50);
  492. const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
  493. resizeObserverCanvas.observe($canvas);
  494. },
  495.  
  496. function mapOpacityControls() {
  497. const $map = document.querySelector('.container canvas');
  498. if (!$map.parentNode.classList.contains('js-map')) {
  499. $map.parentNode.classList.add('js-map');
  500. }
  501. const $mapContainer = document.querySelector('.js-map');
  502.  
  503. // On load, update map opacity to match state
  504. // We modify the opacity of the canvas and the background color alpha of the parent container
  505. // We do this to allow our opacity buttons to be visible on hover with 100% opacity
  506. // (A surprisingly difficult enough task to require this implementation)
  507. const updateMapOpacity = () => {
  508. $map.setAttribute('style', `opacity: ${String(state.mapOpacity / 100)}`);
  509. const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color');
  510. // Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript
  511. const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${state.mapOpacity / 100})`);
  512. $mapContainer.setAttribute('style', `background-color: ${newBgColor}`);
  513. };
  514. updateMapOpacity();
  515.  
  516. const $opacityButtons = makeElement({
  517. element: 'div',
  518. class: 'js-map-opacity',
  519. content: `<button class="js-map-opacity-add">+</button><button class="js-map-opacity-minus">-</button>`,
  520. });
  521.  
  522. // Add it right before the map container div
  523. $map.parentNode.insertBefore($opacityButtons, $map);
  524.  
  525. const $addBtn = document.querySelector('.js-map-opacity-add');
  526. const $minusBtn = document.querySelector('.js-map-opacity-minus');
  527.  
  528. // Hide the buttons if map opacity is maxed/minimum
  529. if (state.mapOpacity === 100) {
  530. $addBtn.setAttribute('style', 'visibility: hidden');
  531. }
  532. if (state.mapOpacity === 0) {
  533. $minusBtn.setAttribute('style', 'visibility: hidden');
  534. }
  535.  
  536. // Wire it up
  537. $addBtn.addEventListener('click', clickEvent => {
  538. // Update opacity
  539. state.mapOpacity += 10;
  540. save();
  541. updateMapOpacity();
  542.  
  543. // Hide this button if the opacity is max
  544. if (state.mapOpacity === 100) {
  545. const $btn = clickEvent.target;
  546. // We use visibility to hide the button but keep its position in the UI
  547. $btn.setAttribute('style', 'visibility: hidden');
  548. }
  549. // If map opacity is not the lowest, then make minus button visible
  550. if (state.mapOpacity !== 0) {
  551. $minusBtn.setAttribute('style', 'visibility: visible');
  552. }
  553. save();
  554. });
  555.  
  556. $minusBtn.addEventListener('click', clickEvent => {
  557. // Update opacity
  558. state.mapOpacity -= 10;
  559. save();
  560. updateMapOpacity();
  561.  
  562. // Hide this button if the opacity is lowest
  563. if (state.mapOpacity === 0) {
  564. const $btn = clickEvent.target;
  565. $btn.setAttribute('style', 'visibility: hidden');
  566. }
  567. // If map opacity is not the max, then make add button visible
  568. if (state.mapOpacity !== 100) {
  569. $addBtn.setAttribute('style', 'visibility: visible');
  570. }
  571. });
  572. },
  573.  
  574. // The last clicked UI window displays above all other UI windows
  575. // This is useful when, for example, your inventory is near the market window,
  576. // and you want the window and the tooltips to display above the market window.
  577. function selectedWindowIsTop() {
  578. Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
  579. $window.classList.add('js-is-top-initd');
  580.  
  581. $window.addEventListener('mousedown', () => {
  582. // First, make the other is-top window not is-top
  583. const $otherWindowContainer = document.querySelector('.js-is-top');
  584. if ($otherWindowContainer) {
  585. $otherWindowContainer.classList.remove('js-is-top');
  586. }
  587.  
  588. // Then, make our window's container (the z-index container) is-top
  589. $window.parentNode.classList.add('js-is-top');
  590. });
  591. });
  592. },
  593.  
  594. // The F icon and the UI that appears when you click it
  595. function customFriendsList() {
  596. var friendsIconElement = makeElement({
  597. element: 'div',
  598. class: 'btn border black js-friends-list-icon',
  599. content: 'F',
  600. });
  601. // Add the icon to the right of Elixir icon
  602. const $elixirIcon = document.querySelector('#sysgem');
  603. $elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling);
  604.  
  605. // Create the friends list UI
  606. document.querySelector('.js-friends-list-icon').addEventListener('click', () => {
  607. if (document.querySelector('.js-friends-list')) {
  608. // Don't open the friends list twice.
  609. return;
  610. }
  611. let friendsListHTML = '';
  612. Object.keys(state.friendsList).sort().forEach(friendName => {
  613. friendsListHTML += `
  614. <div data-player-name="${friendName}">${friendName}</div>
  615. <div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div>
  616. <div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div>
  617. <div class="btn orange js-unfriend-player" data-player-name="${friendName}">Remove</div>
  618. `;
  619. });
  620.  
  621. const customFriendsWindowHTML = `
  622. <h3 class="textprimary">Friends list</h3>
  623. <div class="uimod-friends">${friendsListHTML}</div>
  624. <p></p>
  625. <div class="btn purp js-close-custom-friends-list">Close</div>
  626. `;
  627.  
  628. const $customFriendsList = makeElement({
  629. element: 'div',
  630. class: 'menu panel-black js-friends-list uimod-custom-window',
  631. content: customFriendsWindowHTML,
  632. });
  633. document.body.appendChild($customFriendsList);
  634.  
  635. // Wire up the buttons
  636. Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => {
  637. $button.addEventListener('click', clickEvent => {
  638. const name = clickEvent.target.getAttribute('data-player-name');
  639. modHelpers.whisperPlayer(name);
  640. });
  641. });
  642. Array.from(document.querySelectorAll('.js-party-player')).forEach($button => {
  643. $button.addEventListener('click', clickEvent => {
  644. const name = clickEvent.target.getAttribute('data-player-name');
  645. modHelpers.partyPlayer(name);
  646. });
  647. });
  648. Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => {
  649. $button.addEventListener('click', clickEvent => {
  650. const name = clickEvent.target.getAttribute('data-player-name');
  651. modHelpers.unfriendPlayer(name);
  652.  
  653. // Remove the blocked player from the list
  654. Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => {
  655. $element.parentNode.removeChild($element);
  656. });
  657. });
  658. });
  659.  
  660. // The close button for our custom UI
  661. document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => {
  662. const $friendsListWindow = document.querySelector('.js-friends-list');
  663. $friendsListWindow.parentNode.removeChild($friendsListWindow);
  664. });
  665. });
  666. },
  667.  
  668. // Custom settings UI, currently just Blocked players
  669. function customSettings() {
  670. const $settings = document.querySelector('.divide:not(.js-settings-initd)');
  671. if (!$settings) {
  672. return;
  673. }
  674.  
  675. $settings.classList.add('js-settings-initd');
  676. const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
  677. $settingsChoiceList.appendChild(makeElement({
  678. element: 'div',
  679. class: 'choice js-blocked-players',
  680. content: 'Blocked players',
  681. }));
  682.  
  683. // Upon click, we display our custom settings window UI
  684. document.querySelector('.js-blocked-players').addEventListener('click', () => {
  685. let blockedPlayersHTML = '';
  686. Object.keys(state.blockList).sort().forEach(blockedName => {
  687. blockedPlayersHTML += `
  688. <div data-player-name="${blockedName}">${blockedName}</div>
  689. <div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
  690. `;
  691. });
  692.  
  693. const customSettingsHTML = `
  694. <h3 class="textprimary">Blocked players</h3>
  695. <div class="settings uimod-settings">${blockedPlayersHTML}</div>
  696. <p></p>
  697. <div class="btn purp js-close-custom-settings">Close</div>
  698. `;
  699.  
  700. const $customSettings = makeElement({
  701. element: 'div',
  702. class: 'menu panel-black js-custom-settings uimod-custom-window',
  703. content: customSettingsHTML,
  704. });
  705. document.body.appendChild($customSettings);
  706.  
  707. // Wire up all the unblock buttons
  708. Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
  709. $button.addEventListener('click', clickEvent => {
  710. const name = clickEvent.target.getAttribute('data-player-name');
  711. modHelpers.unblockPlayer(name);
  712.  
  713. // Remove the blocked player from the list
  714. Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => {
  715. $element.parentNode.removeChild($element);
  716. });
  717. });
  718. });
  719. // And the close button for our custom UI
  720. document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
  721. const $customSettingsWindow = document.querySelector('.js-custom-settings');
  722. $customSettingsWindow.parentNode.removeChild($customSettingsWindow);
  723. });
  724. });
  725. },
  726.  
  727. // This creates the initial chat context menu (which starts as hidden)
  728. function createChatContextMenu() {
  729. if (document.querySelector('.js-chat-context-menu')) {
  730. return;
  731. }
  732.  
  733. let contextMenuHTML = `
  734. <div class="js-name">...</div>
  735. <div class="choice" name="party">Party invite</div>
  736. <div class="choice" name="whisper">Whisper</div>
  737. <div class="choice" name="friend">Friend</div>
  738. <div class="choice" name="unfriend">Unfriend</div>
  739. <div class="choice" name="block">Block</div>
  740. `
  741. document.body.appendChild(makeElement({
  742. element: 'div',
  743. class: 'panel context border grey js-chat-context-menu',
  744. content: contextMenuHTML,
  745. }));
  746.  
  747. const $chatContextMenu = document.querySelector('.js-chat-context-menu');
  748. $chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
  749. modHelpers.partyPlayer(tempState.chatName);
  750. });
  751. $chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
  752. modHelpers.whisperPlayer(tempState.chatName);
  753. });
  754. $chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => {
  755. modHelpers.friendPlayer(tempState.chatName);
  756. });
  757. $chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => {
  758. modHelpers.unfriendPlayer(tempState.chatName);
  759. });
  760. $chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
  761. modHelpers.blockPlayer(tempState.chatName);
  762. });
  763. },
  764.  
  765. // This opens a context menu when you click a user's name in chat
  766. function chatContextMenu() {
  767. Array.from(document.querySelectorAll('.name:not(.js-is-context-menu-initd)')).forEach($name => {
  768. $name.classList.add('js-is-context-menu-initd');
  769. // Add name to element so we can target it in CSS when filtering chat for block list
  770. $name.setAttribute('data-chat-name', $name.textContent);
  771.  
  772. const showContextMenu = clickEvent => {
  773. // TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
  774. tempState.chatName = $name.textContent;
  775. modHelpers.showChatContextMenu($name.textContent, {x: clickEvent.pageX, y: clickEvent.pageY});
  776. };
  777. $name.addEventListener('click', showContextMenu);
  778. });
  779. },
  780. ];
  781.  
  782. // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
  783. function initialize() {
  784. // If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
  785. // We check for that and reinitialize the mod if that happens
  786. const $layout = document.querySelector('.layout');
  787. if ($layout.classList.contains('uimod-initd')) {
  788. return;
  789. }
  790.  
  791. modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);
  792.  
  793. $layout.classList.add('uimod-initd')
  794. load();
  795. mods.forEach(mod => mod());
  796.  
  797. // Continuously re-run specific mods methods that need to be executed on UI change
  798. const rerunObserver = new MutationObserver(() => {
  799. // If new window appears, e.g. even if window is closed and reopened, we need to rewire it
  800. // Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
  801. // But some windows only exist in the DOM when open, e.g. Interaction
  802. const modsToRerun = [
  803. 'saveDraggedUIWindows',
  804. 'draggableUIWindows',
  805. 'loadDraggedUIWindowsPositions',
  806. 'selectedWindowIsTop',
  807. 'customSettings',
  808. ];
  809. modsToRerun.forEach(modName => {
  810. mods.find(mod => mod.name === modName)();
  811. });
  812. });
  813. rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });
  814.  
  815. // Rerun only on chat
  816. const chatRerunObserver = new MutationObserver(() => {
  817. mods.find(mod => mod.name === 'chatContextMenu')();
  818. modHelpers.filterAllChat();
  819.  
  820. });
  821. chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });
  822.  
  823. // Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
  824. if (!document.body.classList.contains('js-uimod-initd')) {
  825. document.body.classList.add('js-uimod-initd');
  826.  
  827. // Close chat context menu when clicking outside of it
  828. document.body.addEventListener('click', modHelpers.closeChatContextMenu);
  829. }
  830. }
  831.  
  832. // Initialize mods once UI DOM has loaded
  833. const pageObserver = new MutationObserver(() => {
  834. const isUiLoaded = !!document.querySelector('.layout');
  835. if (isUiLoaded) {
  836. initialize();
  837. }
  838. });
  839. pageObserver.observe(document.body, { attributes: true, childList: true })
  840.  
  841. // UTIL METHODS
  842. // Save to in-memory state and localStorage to retain on refresh
  843. function save(items) {
  844. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  845. }
  846.  
  847. // Load localStorage state if it exists
  848. // NOTE: If user is trying to load unsupported version of stored state,
  849. // e.g. they just upgraded to breaking version, then we delete their stored state
  850. function load() {
  851. const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
  852. if (storedStateJson) {
  853. const storedState = JSON.parse(storedStateJson);
  854. if (storedState.breakingVersion !== BREAKING_VERSION) {
  855. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  856. return;
  857. }
  858. state = {
  859. ...state,
  860. ...storedState,
  861. };
  862. }
  863. }
  864.  
  865. // Nicer impl to create elements in one method call
  866. function makeElement(args) {
  867. const $node = document.createElement(args.element);
  868. if (args.class) { $node.className = args.class; }
  869. if (args.content) { $node.innerHTML = args.content; }
  870. if (args.src) { $node.src = args.src; }
  871. return $node;
  872. }
  873.  
  874. function enterTextIntoChat(text) {
  875. // Open chat input
  876. const enterEvent = new KeyboardEvent('keydown', {
  877. bubbles: true, cancelable: true, keyCode: 13
  878. });
  879. document.body.dispatchEvent(enterEvent);
  880.  
  881. // Place text into chat
  882. const $input = document.querySelector('#chatinput input');
  883. $input.value = text;
  884.  
  885. // Get chat input to recognize slash commands and change the channel
  886. // by triggering the `input` event.
  887. // (Did some debugging to figure out the channel only changes when the
  888. // svelte `input` event listener exists.)
  889. const inputEvent = new KeyboardEvent('input', {
  890. bubbles: true, cancelable: true
  891. })
  892. $input.dispatchEvent(inputEvent);
  893. }
  894.  
  895. function submitChat() {
  896. const $input = document.querySelector('#chatinput input');
  897. const kbEvent = new KeyboardEvent('keydown', {
  898. bubbles: true, cancelable: true, keyCode: 13
  899. });
  900. $input.dispatchEvent(kbEvent);
  901. }
  902.  
  903. // Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
  904. // $draggedElement is the item that will be dragged.
  905. // $dragTrigger is the element that must be held down to drag $draggedElement
  906. function dragElement($draggedElement, $dragTrigger) {
  907. let offset = [0,0];
  908. let isDown = false;
  909. $dragTrigger.addEventListener('mousedown', function(e) {
  910. isDown = true;
  911. offset = [
  912. $draggedElement.offsetLeft - e.clientX,
  913. $draggedElement.offsetTop - e.clientY
  914. ];
  915. }, true);
  916. document.addEventListener('mouseup', function() {
  917. isDown = false;
  918. }, true);
  919.  
  920. document.addEventListener('mousemove', function(e) {
  921. event.preventDefault();
  922. if (isDown) {
  923. $draggedElement.style.left = (e.clientX + offset[0]) + 'px';
  924. $draggedElement.style.top = (e.clientY + offset[1]) + 'px';
  925. }
  926. }, true);
  927. }
  928.  
  929. // Credit: David Walsh
  930. function debounce(func, wait, immediate) {
  931. var timeout;
  932. return function() {
  933. var context = this, args = arguments;
  934. var later = function() {
  935. timeout = null;
  936. if (!immediate) func.apply(context, args);
  937. };
  938. var callNow = immediate && !timeout;
  939. clearTimeout(timeout);
  940. timeout = setTimeout(later, wait);
  941. if (callNow) func.apply(context, args);
  942. };
  943. }
  944. })();