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