UX Improvements

Add many UI improvements and additions

当前为 2024-01-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name UX Improvements
  3. // @author commander
  4. // @description Add many UI improvements and additions
  5. // @namespace https://github.com/asger-finding/tanktrouble-userscripts
  6. // @version 0.0.3
  7. // @license GPL-3.0
  8. // @match *://*.tanktrouble.com/*
  9. // @exclude *://classic.tanktrouble.com/
  10. // @run-at document-end
  11. // @grant GM_addStyle
  12. // @require https://update.greasyfork.org/scripts/482092/1309109/TankTrouble%20Development%20Library.js
  13. // @noframes
  14. // ==/UserScript==
  15.  
  16. // TODO: Button to render high-res tanks no outline in TankInfoBox
  17. // TODO: Minimum game quality setting
  18. // TODO: Lobby games carousel
  19.  
  20. const ranges = {
  21. years: 3600 * 24 * 365,
  22. months: (365 * 3600 * 24) / 12,
  23. weeks: 3600 * 24 * 7,
  24. days: 3600 * 24,
  25. hours: 3600,
  26. minutes: 60,
  27. seconds: 1
  28. };
  29.  
  30. /**
  31. * Format a timestamp to relative time ago from now
  32. * @param date Date object
  33. * @returns Time ago
  34. */
  35. const timeAgo = date => {
  36. const formatter = new Intl.RelativeTimeFormat('en');
  37. const secondsElapsed = (date.getTime() - Date.now()) / 1000;
  38.  
  39. for (const key in ranges) {
  40. if (ranges[key] < Math.abs(secondsElapsed)) {
  41. const delta = secondsElapsed / ranges[key];
  42. return formatter.format(Math.round(delta), key);
  43. }
  44. }
  45.  
  46. return 'now';
  47. };
  48.  
  49. (() => {
  50. // Fix no log bound to UIGoldSprite and UIDiamondSprite - causes crashes sometimes
  51. return;
  52.  
  53. Loader.interceptFunction(unsafeWindow, 'UIDiamondSprite', (original, ...args) => {
  54. const result = original(...args);
  55.  
  56. console.log(result);
  57.  
  58. return result;
  59. });
  60.  
  61. Loader.interceptFunction(unsafeWindow, 'UIGoldSprite', (original, ...args) => {
  62. const result = original(...args);
  63.  
  64. console.log(result);
  65.  
  66. return result;
  67. });
  68. })();
  69.  
  70. (() => {
  71. GM_addStyle(`
  72. .forum .tanks {
  73. position: absolute;
  74. }
  75. .forum .reply.left .tanks {
  76. left: 0;
  77. }
  78. .forum .reply.right .tanks {
  79. right: 0;
  80. }
  81. .forum .tanks.tankCount2 {
  82. transform: scale(0.8);
  83. }
  84. .forum .tanks.tankCount3 {
  85. transform: scale(0.6);
  86. }
  87. .forum .tank.coCreator1 {
  88. position: absolute;
  89. transform: translate(-55px, 0px);
  90. }
  91. .forum .tank.coCreator2 {
  92. position: absolute;
  93. transform: translate(-110px, 0px);
  94. }
  95. .forum .reply.right .tank.coCreator1 {
  96. position: absolute;
  97. transform: translate(55px, 0px);
  98. }
  99. .forum .reply.right .tank.coCreator2 {
  100. position: absolute;
  101. transform: translate(110px, 0px);
  102. }
  103. .forum .share img {
  104. display: none;
  105. }
  106. .forum .thread .share:not(:active) .standard,
  107. .forum .thread .share:active .active {
  108. display: inherit;
  109. }
  110. .forum .reply .share:not(:active) .standard,
  111. .forum .reply .share:active .active {
  112. display: inherit;
  113. }
  114. `);
  115.  
  116. // The jquery SVG plugin does not support the newer paint-order attribute
  117. $.svg._attrNames.paintOrder = 'paint-order';
  118.  
  119. /**
  120. * Add tank previews for all thread creators, not just the primary creator
  121. * @param threadOrReply Post data
  122. * @param threadOrReplyElement Parsed post element
  123. */
  124. const insertMultipleCreators = (threadOrReply, threadOrReplyElement) => {
  125. // Remove original tank preview
  126. threadOrReplyElement.find('.tank').remove();
  127.  
  128. const creators = {
  129. ...{ creator: threadOrReply.creator },
  130. ...threadOrReply.coCreator1 && { coCreator1: threadOrReply.coCreator1 },
  131. ...threadOrReply.coCreator2 && { coCreator2: threadOrReply.coCreator2 }
  132. };
  133. const creatorsContainer = $('<div/>')
  134. .addClass(`tanks tankCount${Object.keys(creators).length}`)
  135. .insertBefore(threadOrReplyElement.find('.container'));
  136.  
  137. // Render all creator tanks in canvas
  138. for (const [creatorType, playerId] of Object.entries(creators)) {
  139. const wrapper = document.createElement('div');
  140. wrapper.classList.add('tank', creatorType);
  141.  
  142. const canvas = document.createElement('canvas');
  143. canvas.width = UIConstants.TANK_ICON_WIDTH_SMALL;
  144. canvas.height = UIConstants.TANK_ICON_HEIGHT_SMALL;
  145. canvas.style.width = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] }px`;
  146. canvas.style.height = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] * 0.6 }px`;
  147. canvas.addEventListener('mouseup', () => {
  148. const rect = canvas.getBoundingClientRect();
  149. const win = canvas.ownerDocument.defaultView;
  150.  
  151. const top = rect.top + win.scrollY;
  152. const left = rect.left + win.scrollX;
  153.  
  154. TankTrouble.TankInfoBox.show(left + (canvas.clientWidth / 2), top + (canvas.clientHeight / 2), playerId, canvas.clientWidth / 2, canvas.clientHeight / 4);
  155. });
  156. UITankIcon.loadPlayerTankIcon(canvas, UIConstants.TANK_ICON_SIZES.SMALL, playerId);
  157.  
  158. wrapper.append(canvas);
  159. creatorsContainer.append(wrapper);
  160. }
  161.  
  162. // Render name of primary creator
  163. Backend.getInstance().getPlayerDetails(result => {
  164. const creatorName = $('<div/>');
  165. const username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';
  166.  
  167. // FIXME: Too-long names clip the svg container
  168. creatorName.svg({
  169. settings: {
  170. width: UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10,
  171. height: 25
  172. }
  173. });
  174. const nameSvg = creatorName.svg('get');
  175. const nameText = nameSvg.text('50%', 0, username, {
  176. textAnchor: 'middle',
  177. dominantBaseline: 'text-before-edge',
  178. fontFamily: 'TankTrouble',
  179. fontWeight: 'normal',
  180. fontSize: '80%',
  181. fill: 'white',
  182. stroke: 'black',
  183. strokeLineJoin: 'round',
  184. strokeWidth: 2,
  185. paintOrder: 'stroke'
  186. });
  187. nameSvg.configure(nameText);
  188. creatorsContainer.find('.tank.creator').append(creatorName);
  189. }, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
  190. };
  191.  
  192. /**
  193. * Insert a share button to the thread or reply that copies the link to the post to clipboard
  194. * @param threadOrReply Post data
  195. * @param threadOrReplyElement Parsed post element
  196. */
  197. const addShareButton = (threadOrReply, threadOrReplyElement) => {
  198. const likeAction = threadOrReplyElement.find('.action.like');
  199.  
  200. let shareAction = $('<div class="action share"></div>');
  201. const shareActionStandardImage = $('<img class="standard" src="https://i.imgur.com/emJXwew.png" srcset="https://i.imgur.com/UF4gXBk.png 2x"/>');
  202. const shareActionActiveImage = $('<img class="active" src="https://i.imgur.com/pNQ0Aja.png" srcset="https://i.imgur.com/Ti3IplV.png 2x"/>');
  203.  
  204. shareAction.append([shareActionStandardImage, shareActionActiveImage]);
  205. likeAction.after(shareAction);
  206.  
  207. // Replies have a duplicate actions container for
  208. // both right and left-facing replies.
  209. // So when the share button is appended, there may be multiple
  210. // and so we need to realize those instances as well
  211. shareAction = threadOrReplyElement.find('.action.share');
  212.  
  213. shareAction.tooltipster({
  214. position: 'top',
  215. offsetY: 5,
  216.  
  217. /** Reset tooltipster when mouse leaves */
  218. functionAfter: () => {
  219. shareAction.tooltipster('content', 'Copy post to clipboard');
  220. }
  221. });
  222. shareAction.tooltipster('content', 'Copy post to clipboard');
  223.  
  224. shareAction.on('mouseup', () => {
  225. const url = new URL('/forum', window.location.origin);
  226.  
  227. url.searchParams.set('id', threadOrReply.id);
  228. if (threadOrReply.threadId) url.searchParams.set('threadId', threadOrReply.threadId);
  229.  
  230. ClipboardManager.copy(url.href);
  231.  
  232. shareAction.tooltipster('content', 'Copied!');
  233. });
  234. };
  235.  
  236. /**
  237. * Add text to details that shows when a post was last edited
  238. * @param threadOrReply Post data
  239. * @param threadOrReplyElement Parsed post element
  240. */
  241. const addLastEdited = (threadOrReply, threadOrReplyElement) => {
  242. const { created, latestEdit } = threadOrReply;
  243.  
  244. if (latestEdit) {
  245. const details = threadOrReplyElement.find('.bubble .details');
  246. const detailsText = details.text();
  247. const replyIndex = detailsText.indexOf('-');
  248. const lastReply = replyIndex !== -1
  249. ? ` - ${ detailsText.slice(replyIndex + 1).trim()}`
  250. : '';
  251.  
  252. const createdAgo = timeAgo(new Date(created * 1000));
  253. const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;
  254.  
  255. details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
  256. }
  257. };
  258.  
  259. /**
  260. * Add extra features to a thread or reply
  261. * @param threadOrReply Post data
  262. */
  263. const addFeaturesToThreadOrReply = threadOrReply => {
  264. // FIXME: Threads and replies sometimes bug out. Investigate!
  265. const [key] = Object.keys(threadOrReply.html);
  266. const html = threadOrReply.html[key];
  267.  
  268. if (typeof html === 'string') {
  269. const threadOrReplyElement = $($.parseHTML(html));
  270.  
  271. insertMultipleCreators(threadOrReply, threadOrReplyElement);
  272. addLastEdited(threadOrReply, threadOrReplyElement);
  273. addShareButton(threadOrReply, threadOrReplyElement);
  274.  
  275. threadOrReply.html[key] = threadOrReplyElement;
  276. }
  277. };
  278.  
  279. const threadListChanged = ForumView.getMethod('threadListChanged');
  280. ForumView.method('threadListChanged', function(...args) {
  281. const threadList = args.shift();
  282. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  283.  
  284. const result = threadListChanged.apply(this, [threadList, ...args]);
  285. return result;
  286. });
  287.  
  288. const replyListChanged = ForumView.getMethod('replyListChanged');
  289. ForumView.method('replyListChanged', function(...args) {
  290. const threadList = args.shift();
  291. for (const thread of threadList) addFeaturesToThreadOrReply(thread);
  292.  
  293. const result = replyListChanged.apply(this, [threadList, ...args]);
  294. return result;
  295. });
  296.  
  297. const getSelectedThread = ForumModel.getMethod('getSelectedThread');
  298. ForumModel.method('getSelectedThread', function(...args) {
  299. const result = getSelectedThread.apply(this, [...args]);
  300.  
  301. addFeaturesToThreadOrReply(result);
  302.  
  303. return result;
  304. });
  305. })();
  306.  
  307. (() => {
  308. Loader.interceptFunction(TankTrouble.AccountOverlay, '_initialize', (original, ...args) => {
  309. original(...args);
  310.  
  311. TankTrouble.AccountOverlay.accountCreatedText = $('<div></div>');
  312. TankTrouble.AccountOverlay.accountCreatedText.insertAfter(TankTrouble.AccountOverlay.accountHeadline);
  313. });
  314.  
  315. Loader.interceptFunction(TankTrouble.AccountOverlay, 'show', (original, ...args) => {
  316. original(...args);
  317.  
  318. Backend.getInstance().getPlayerDetails(result => {
  319. if (typeof result === 'object') {
  320. const created = new Date(result.getCreated() * 1000);
  321. const formatted = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full' }).format(created);
  322.  
  323. TankTrouble.AccountOverlay.accountCreatedText.text(`Created: ${formatted} (${timeAgo(created)})`);
  324. }
  325. }, () => {}, () => {}, TankTrouble.AccountOverlay.playerId, Caches.getPlayerDetailsCache());
  326. });
  327. })();
  328.  
  329. (() => {
  330. /**
  331. * Determine player's admin state
  332. * @param playerDetails Player details
  333. * @returns -1 for retired admin, 0 for non-admin, 1 for admin
  334. */
  335. const getAdminState = playerDetails => {
  336. const isAdmin = playerDetails.getGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  337.  
  338. if (isAdmin) return 1;
  339. else if (TankTrouble.WallOfFame.admins.includes(playerDetails.getUsername())) return -1;
  340. return 0;
  341. };
  342.  
  343. /**
  344. * Prepend admin details to username
  345. * @param usernameParts Transformable array for the username
  346. * @param playerDetails Player details
  347. * @returns Mutated username parts
  348. */
  349. const maskUsernameByAdminState = (usernameParts, playerDetails) => {
  350. const adminState = getAdminState(playerDetails);
  351.  
  352. if (adminState === 1) usernameParts.unshift(`(GM${ playerDetails.getGmLevel() }) `);
  353. else if (adminState === -1) usernameParts.unshift('(Retd.) ');
  354.  
  355. return usernameParts;
  356. };
  357.  
  358. /**
  359. * Mask username if not yet approved
  360. * If the user or an admin is logged in
  361. * locally, then still show the username
  362. * @param usernameParts Transformable array for the username
  363. * @param playerDetails Player details
  364. * @returns Mutated username parts
  365. */
  366. const maskUnapprovedUsername = (usernameParts, playerDetails) => {
  367. if (!playerDetails.getUsernameApproved()) {
  368. const playerLoggedIn = Users.isAnyUser(playerDetails.getPlayerId());
  369. const anyAdminLoggedIn = Users.getHighestGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;
  370.  
  371. if (playerLoggedIn || anyAdminLoggedIn) {
  372. usernameParts.unshift('× ');
  373. usernameParts.push(playerDetails.getUsername(), ' ×');
  374. } else {
  375. usernameParts.length = 0;
  376. usernameParts.push('× × ×');
  377. }
  378. } else {
  379. usernameParts.push(playerDetails.getUsername());
  380. }
  381.  
  382. return usernameParts;
  383. };
  384.  
  385. /**
  386. * Transforms the player's username
  387. * depending on parameters admin and username approved
  388. * @param playerDetails Player details
  389. * @returns New username
  390. */
  391. const transformUsername = playerDetails => {
  392. const usernameParts = [];
  393.  
  394. maskUnapprovedUsername(usernameParts, playerDetails);
  395. maskUsernameByAdminState(usernameParts, playerDetails);
  396.  
  397. return usernameParts.join('');
  398. };
  399.  
  400. Utils.classMethod('maskUnapprovedUsername', playerDetails => transformUsername(playerDetails));
  401. })();
  402.  
  403. (() => {
  404. GM_addStyle(`
  405. .walletIcon {
  406. object-fit: contain;
  407. margin-right: 6px;
  408. }
  409. `);
  410.  
  411. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, '_initialize', (original, ...args) => {
  412. original(...args);
  413.  
  414. // Initialize wallet elements
  415. TankTrouble.VirtualShopOverlay.walletGold = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  416. TankTrouble.VirtualShopOverlay.walletDiamonds = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
  417. TankTrouble.VirtualShopOverlay.navigation.append([TankTrouble.VirtualShopOverlay.walletGold, TankTrouble.VirtualShopOverlay.walletDiamonds]);
  418. });
  419.  
  420. Loader.interceptFunction(TankTrouble.VirtualShopOverlay, 'show', (original, ...args) => {
  421. original(...args);
  422.  
  423. const [params] = args;
  424. Backend.getInstance().getCurrency(result => {
  425. if (typeof result === 'object') {
  426. // Set wallet currency from result
  427. const goldButton = TankTrouble.VirtualShopOverlay.walletGold.find('button').empty();
  428. const diamondsButton = TankTrouble.VirtualShopOverlay.walletDiamonds.find('button').empty();
  429.  
  430. Utils.addImageWithClasses(goldButton, 'walletIcon', 'assets/images/virtualShop/gold.png');
  431. goldButton.append(result.getGold());
  432. Utils.addImageWithClasses(diamondsButton, 'walletIcon', 'assets/images/virtualShop/diamond.png');
  433. diamondsButton.append(result.getDiamonds());
  434. }
  435. }, () => {}, () => {}, params.playerId, Caches.getCurrencyCache());
  436. });
  437. })();
  438.  
  439. (() => {
  440. Loader.interceptFunction(TankTrouble.TankInfoBox, '_initialize', (original, ...args) => {
  441. original(...args);
  442.  
  443. // Initialize death info elements
  444. TankTrouble.TankInfoBox.infoDeathsDiv = $('<tr/>');
  445. TankTrouble.TankInfoBox.infoDeathsIcon = $('<img class="statsIcon" src="https://i.imgur.com/PMAUKdq.png" srcset="https://i.imgur.com/vEjIwA4.png 2x"/>');
  446. TankTrouble.TankInfoBox.infoDeaths = $('<div/>');
  447.  
  448. // Align to center
  449. TankTrouble.TankInfoBox.infoDeathsDiv.css({
  450. display: 'flex',
  451. 'align-items': 'center',
  452. margin: '0 auto',
  453. width: 'fit-content'
  454. });
  455.  
  456. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster({
  457. position: 'left',
  458. offsetX: 5
  459. });
  460.  
  461. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeathsIcon);
  462. TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeaths);
  463. TankTrouble.TankInfoBox.infoDeathsDiv.insertAfter(TankTrouble.TankInfoBox.infoTable);
  464.  
  465. TankTrouble.TankInfoBox.infoDeaths.svg({
  466. settings: {
  467. width: UIConstants.TANK_INFO_MAX_NUMBER_WIDTH,
  468. height: 34
  469. }
  470. });
  471. TankTrouble.TankInfoBox.infoDeathsSvg = TankTrouble.TankInfoBox.infoDeaths.svg('get');
  472. });
  473.  
  474. Loader.interceptFunction(TankTrouble.TankInfoBox, 'show', (original, ...args) => {
  475. original(...args);
  476.  
  477. TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster('content', 'Deaths');
  478. TankTrouble.TankInfoBox.infoDeathsSvg.clear();
  479.  
  480. const [,, playerId] = args;
  481.  
  482. Backend.getInstance().getPlayerDetails(result => {
  483. const deaths = typeof result === 'object' ? result.getDeaths() : 'N/A';
  484.  
  485. const deathsText = TankTrouble.TankInfoBox.infoDeathsSvg.text(1, 22, deaths.toString(), {
  486. textAnchor: 'start',
  487. fontFamily: 'Arial Black',
  488. fontSize: 14,
  489. fill: 'white',
  490. stroke: 'black',
  491. strokeLineJoin: 'round',
  492. strokeWidth: 3,
  493. letterSpacing: 1,
  494. paintOrder: 'stroke'
  495. });
  496. const deathsLength = Utils.measureSVGText(deaths.toString(), {
  497. fontFamily: 'Arial Black',
  498. fontSize: 14
  499. });
  500.  
  501. scaleAndTranslate = Utils.getSVGScaleAndTranslateToFit(UIConstants.TANK_INFO_MAX_NUMBER_WIDTH, deathsLength + 7, 34, 'left');
  502. TankTrouble.TankInfoBox.infoDeathsSvg.configure(deathsText, { transform: scaleAndTranslate });
  503. }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
  504. });
  505. })();