UX Improvements

Add many UI improvements and additions

目前为 2024-01-18 提交的版本。查看 最新版本

// ==UserScript==
// @name        UX Improvements
// @author      commander
// @description Add many UI improvements and additions
// @namespace   https://github.com/asger-finding/tanktrouble-userscripts
// @version     0.0.3
// @license     GPL-3.0
// @match       *://*.tanktrouble.com/*
// @exclude     *://classic.tanktrouble.com/
// @run-at      document-end
// @grant       GM_addStyle
// @require     https://update.gf.qytechs.cn/scripts/482092/1309109/TankTrouble%20Development%20Library.js
// @noframes
// ==/UserScript==

// TODO: Button to render high-res tanks no outline in TankInfoBox
// TODO: Minimum game quality setting
// TODO: Lobby games carousel

const ranges = {
	years: 3600 * 24 * 365,
	months: (365 * 3600 * 24) / 12,
	weeks: 3600 * 24 * 7,
	days: 3600 * 24,
	hours: 3600,
	minutes: 60,
	seconds: 1
};

/**
 * Format a timestamp to relative time ago from now
 * @param date Date object
 * @returns Time ago
 */
const timeAgo = date => {
	const formatter = new Intl.RelativeTimeFormat('en');
	const secondsElapsed = (date.getTime() - Date.now()) / 1000;

	for (const key in ranges) {
		if (ranges[key] < Math.abs(secondsElapsed)) {
			const delta = secondsElapsed / ranges[key];
			return formatter.format(Math.round(delta), key);
		}
	}

	return 'now';
};

(() => {
	// Fix no log bound to UIGoldSprite and UIDiamondSprite - causes crashes sometimes
	return;

	Loader.interceptFunction(unsafeWindow, 'UIDiamondSprite', (original, ...args) => {
		const result = original(...args);

		console.log(result);

		return result;
	});

	Loader.interceptFunction(unsafeWindow, 'UIGoldSprite', (original, ...args) => {
		const result = original(...args);

		console.log(result);

		return result;
	});
})();

(() => {
	GM_addStyle(`
	.forum .tanks {
		position: absolute;
	}
	.forum .reply.left .tanks {
		left: 0;
	}
	.forum .reply.right .tanks {
		right: 0;
	}
	.forum .tanks.tankCount2 {
		transform: scale(0.8);
	}
	.forum .tanks.tankCount3 {
		transform: scale(0.6);
	}
	.forum .tank.coCreator1 {
		position: absolute;
		transform: translate(-55px, 0px);
	}
	.forum .tank.coCreator2 {
		position: absolute;
		transform: translate(-110px, 0px);
	}
	.forum .reply.right .tank.coCreator1 {
		position: absolute;
		transform: translate(55px, 0px);
	}
	.forum .reply.right .tank.coCreator2 {
		position: absolute;
		transform: translate(110px, 0px);
	}
	.forum .share img {
		display: none;
	}
	.forum .thread .share:not(:active) .standard,
	.forum .thread .share:active .active {
		display: inherit;
	}
	.forum .reply .share:not(:active) .standard,
	.forum .reply .share:active .active {
		display: inherit;
	}
	`);

	// The jquery SVG plugin does not support the newer paint-order attribute
	$.svg._attrNames.paintOrder = 'paint-order';

	/**
	 * Add tank previews for all thread creators, not just the primary creator
	 * @param threadOrReply Post data
	 * @param threadOrReplyElement Parsed post element
	 */
	const insertMultipleCreators = (threadOrReply, threadOrReplyElement) => {
		// Remove original tank preview
		threadOrReplyElement.find('.tank').remove();

		const creators = {
			...{ creator: threadOrReply.creator },
			...threadOrReply.coCreator1 && { coCreator1: threadOrReply.coCreator1 },
			...threadOrReply.coCreator2 && { coCreator2: threadOrReply.coCreator2 }
		};
		const creatorsContainer = $('<div/>')
			.addClass(`tanks tankCount${Object.keys(creators).length}`)
			.insertBefore(threadOrReplyElement.find('.container'));

		// Render all creator tanks in canvas
		for (const [creatorType, playerId] of Object.entries(creators)) {
			const wrapper = document.createElement('div');
			wrapper.classList.add('tank', creatorType);

			const canvas = document.createElement('canvas');
			canvas.width = UIConstants.TANK_ICON_WIDTH_SMALL;
			canvas.height = UIConstants.TANK_ICON_HEIGHT_SMALL;
			canvas.style.width = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] }px`;
			canvas.style.height = `${UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] * 0.6 }px`;
			canvas.addEventListener('mouseup', () => {
				const rect = canvas.getBoundingClientRect();
				const win = canvas.ownerDocument.defaultView;

				const top = rect.top + win.scrollY;
				const left = rect.left + win.scrollX;

				TankTrouble.TankInfoBox.show(left + (canvas.clientWidth / 2), top + (canvas.clientHeight / 2), playerId, canvas.clientWidth / 2, canvas.clientHeight / 4);
			});
			UITankIcon.loadPlayerTankIcon(canvas, UIConstants.TANK_ICON_SIZES.SMALL, playerId);

			wrapper.append(canvas);
			creatorsContainer.append(wrapper);
		}

		// Render name of primary creator
		Backend.getInstance().getPlayerDetails(result => {
			const creatorName = $('<div/>');
			const username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';

			// FIXME: Too-long names clip the svg container
			creatorName.svg({
				settings: {
					width: UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10,
					height: 25
				}
			});
			const nameSvg = creatorName.svg('get');
			const nameText = nameSvg.text('50%', 0, username, {
				textAnchor: 'middle',
				dominantBaseline: 'text-before-edge',
				fontFamily: 'TankTrouble',
				fontWeight: 'normal',
				fontSize: '80%',
				fill: 'white',
				stroke: 'black',
				strokeLineJoin: 'round',
				strokeWidth: 2,
				paintOrder: 'stroke'
			});
			nameSvg.configure(nameText);
			creatorsContainer.find('.tank.creator').append(creatorName);
		}, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
	};

	/**
	 * Insert a share button to the thread or reply that copies the link to the post to clipboard
	 * @param threadOrReply Post data
	 * @param threadOrReplyElement Parsed post element
	 */
	const addShareButton = (threadOrReply, threadOrReplyElement) => {
		const likeAction = threadOrReplyElement.find('.action.like');

		let shareAction = $('<div class="action share"></div>');
		const shareActionStandardImage = $('<img class="standard" src="https://i.imgur.com/emJXwew.png" srcset="https://i.imgur.com/UF4gXBk.png 2x"/>');
		const shareActionActiveImage = $('<img class="active" src="https://i.imgur.com/pNQ0Aja.png" srcset="https://i.imgur.com/Ti3IplV.png 2x"/>');

		shareAction.append([shareActionStandardImage, shareActionActiveImage]);
		likeAction.after(shareAction);

		// Replies have a duplicate actions container for 
		// both right and left-facing replies.
		// So when the share button is appended, there may be multiple
		// and so we need to realize those instances as well
		shareAction = threadOrReplyElement.find('.action.share');

		shareAction.tooltipster({
			position: 'top',
			offsetY: 5,

			/** Reset tooltipster when mouse leaves */
			functionAfter: () => {
				shareAction.tooltipster('content', 'Copy post to clipboard');
			}
		});
		shareAction.tooltipster('content', 'Copy post to clipboard');

		shareAction.on('mouseup', () => {
			const url = new URL('/forum', window.location.origin);

			url.searchParams.set('id', threadOrReply.id);
			if (threadOrReply.threadId) url.searchParams.set('threadId', threadOrReply.threadId);

			ClipboardManager.copy(url.href);

			shareAction.tooltipster('content', 'Copied!');
		});
	};

	/**
	 * Add text to details that shows when a post was last edited
	 * @param threadOrReply Post data
	 * @param threadOrReplyElement Parsed post element
	 */
	const addLastEdited = (threadOrReply, threadOrReplyElement) => {
		const { created, latestEdit } = threadOrReply;

		if (latestEdit) {
			const details = threadOrReplyElement.find('.bubble .details');
			const detailsText = details.text();
			const replyIndex = detailsText.indexOf('-');
			const lastReply = replyIndex !== -1
				? ` - ${ detailsText.slice(replyIndex + 1).trim()}`
				: '';

			const createdAgo = timeAgo(new Date(created * 1000));
			const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;

			details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
		}
	};

	/**
	 * Add extra features to a thread or reply
	 * @param threadOrReply Post data
	 */
	const addFeaturesToThreadOrReply = threadOrReply => {
		// FIXME: Threads and replies sometimes bug out. Investigate!
		const [key] = Object.keys(threadOrReply.html);
		const html = threadOrReply.html[key];

		if (typeof html === 'string') {
			const threadOrReplyElement = $($.parseHTML(html));

			insertMultipleCreators(threadOrReply, threadOrReplyElement);
			addLastEdited(threadOrReply, threadOrReplyElement);
			addShareButton(threadOrReply, threadOrReplyElement);

			threadOrReply.html[key] = threadOrReplyElement;
		}
	};

	const threadListChanged = ForumView.getMethod('threadListChanged');
	ForumView.method('threadListChanged', function(...args) {
		const threadList = args.shift();
		for (const thread of threadList) addFeaturesToThreadOrReply(thread);

		const result = threadListChanged.apply(this, [threadList, ...args]);
		return result;
	});

	const replyListChanged = ForumView.getMethod('replyListChanged');
	ForumView.method('replyListChanged', function(...args) {
		const threadList = args.shift();
		for (const thread of threadList) addFeaturesToThreadOrReply(thread);

		const result = replyListChanged.apply(this, [threadList, ...args]);
		return result;
	});

	const getSelectedThread = ForumModel.getMethod('getSelectedThread');
	ForumModel.method('getSelectedThread', function(...args) {
		const result = getSelectedThread.apply(this, [...args]);

		addFeaturesToThreadOrReply(result);

		return result;
	});
})();

(() => {
	Loader.interceptFunction(TankTrouble.AccountOverlay, '_initialize', (original, ...args) => {
		original(...args);

		TankTrouble.AccountOverlay.accountCreatedText = $('<div></div>');
		TankTrouble.AccountOverlay.accountCreatedText.insertAfter(TankTrouble.AccountOverlay.accountHeadline);
	});

	Loader.interceptFunction(TankTrouble.AccountOverlay, 'show', (original, ...args) => {
		original(...args);

		Backend.getInstance().getPlayerDetails(result => {
			if (typeof result === 'object') {
				const created = new Date(result.getCreated() * 1000);
				const formatted = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full' }).format(created);

				TankTrouble.AccountOverlay.accountCreatedText.text(`Created: ${formatted} (${timeAgo(created)})`);
			}
		}, () => {}, () => {}, TankTrouble.AccountOverlay.playerId, Caches.getPlayerDetailsCache());
	});
})();

(() => {
	/**
	 * Determine player's admin state
	 * @param playerDetails Player details
	 * @returns -1 for retired admin, 0 for non-admin, 1 for admin
	 */
	const getAdminState = playerDetails => {
		const isAdmin = playerDetails.getGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;

		if (isAdmin) return 1;
		else if (TankTrouble.WallOfFame.admins.includes(playerDetails.getUsername())) return -1;
		return 0;
	};

	/**
	 * Prepend admin details to username
	 * @param usernameParts Transformable array for the username
	 * @param playerDetails Player details
	 * @returns Mutated username parts
	 */
	const maskUsernameByAdminState = (usernameParts, playerDetails) => {
		const adminState = getAdminState(playerDetails);

		if (adminState === 1) usernameParts.unshift(`(GM${ playerDetails.getGmLevel() }) `);
		else if (adminState === -1) usernameParts.unshift('(Retd.) ');

		return usernameParts;
	};

	/**
	 * Mask username if not yet approved
	 * If the user or an admin is logged in
	 * locally, then still show the username
	 * @param usernameParts Transformable array for the username
	 * @param playerDetails Player details
	 * @returns Mutated username parts
	 */
	const maskUnapprovedUsername = (usernameParts, playerDetails) => {
		if (!playerDetails.getUsernameApproved()) {
			const playerLoggedIn = Users.isAnyUser(playerDetails.getPlayerId());
			const anyAdminLoggedIn = Users.getHighestGmLevel() >= UIConstants.ADMIN_LEVEL_PLAYER_LOOKUP;

			if (playerLoggedIn || anyAdminLoggedIn) {
				usernameParts.unshift('× ');
				usernameParts.push(playerDetails.getUsername(), ' ×');
			} else {
				usernameParts.length = 0;
				usernameParts.push('× × ×');
			}
		} else {
			usernameParts.push(playerDetails.getUsername());
		}

		return usernameParts;
	};

	/**
	 * Transforms the player's username
	 * depending on parameters admin and username approved
	 * @param playerDetails Player details
	 * @returns New username
	 */
	const transformUsername = playerDetails => {
		const usernameParts = [];

		maskUnapprovedUsername(usernameParts, playerDetails);
		maskUsernameByAdminState(usernameParts, playerDetails);

		return usernameParts.join('');
	};

	Utils.classMethod('maskUnapprovedUsername', playerDetails => transformUsername(playerDetails));
})();

(() => {
	GM_addStyle(`
	.walletIcon {
	  object-fit: contain;
	  margin-right: 6px;
	}
	`);

	Loader.interceptFunction(TankTrouble.VirtualShopOverlay, '_initialize', (original, ...args) => {
		original(...args);

		// Initialize wallet elements
		TankTrouble.VirtualShopOverlay.walletGold = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
		TankTrouble.VirtualShopOverlay.walletDiamonds = $("<div><button class='medium disabled' style='display: flex;'>Loading ...</button></div>");
		TankTrouble.VirtualShopOverlay.navigation.append([TankTrouble.VirtualShopOverlay.walletGold, TankTrouble.VirtualShopOverlay.walletDiamonds]);
	});

	Loader.interceptFunction(TankTrouble.VirtualShopOverlay, 'show', (original, ...args) => {
		original(...args);

		const [params] = args;
		Backend.getInstance().getCurrency(result => {
			if (typeof result === 'object') {
				// Set wallet currency from result
				const goldButton = TankTrouble.VirtualShopOverlay.walletGold.find('button').empty();
				const diamondsButton = TankTrouble.VirtualShopOverlay.walletDiamonds.find('button').empty();

				Utils.addImageWithClasses(goldButton, 'walletIcon', 'assets/images/virtualShop/gold.png');
				goldButton.append(result.getGold());
				Utils.addImageWithClasses(diamondsButton, 'walletIcon', 'assets/images/virtualShop/diamond.png');
				diamondsButton.append(result.getDiamonds());
			}
		}, () => {}, () => {}, params.playerId, Caches.getCurrencyCache());
	});
})();

(() => {
	Loader.interceptFunction(TankTrouble.TankInfoBox, '_initialize', (original, ...args) => {
		original(...args);

		// Initialize death info elements
		TankTrouble.TankInfoBox.infoDeathsDiv = $('<tr/>');
		TankTrouble.TankInfoBox.infoDeathsIcon = $('<img class="statsIcon" src="https://i.imgur.com/PMAUKdq.png" srcset="https://i.imgur.com/vEjIwA4.png 2x"/>');
		TankTrouble.TankInfoBox.infoDeaths = $('<div/>');

		// Align to center
		TankTrouble.TankInfoBox.infoDeathsDiv.css({
			display: 'flex',
			'align-items': 'center',
			margin: '0 auto',
			width: 'fit-content'
		});

		TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster({
			position: 'left',
			offsetX: 5
		});

		TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeathsIcon);
		TankTrouble.TankInfoBox.infoDeathsDiv.append(TankTrouble.TankInfoBox.infoDeaths);
		TankTrouble.TankInfoBox.infoDeathsDiv.insertAfter(TankTrouble.TankInfoBox.infoTable);

		TankTrouble.TankInfoBox.infoDeaths.svg({
			settings: {
				width: UIConstants.TANK_INFO_MAX_NUMBER_WIDTH,
				height: 34
			}
		});
		TankTrouble.TankInfoBox.infoDeathsSvg = TankTrouble.TankInfoBox.infoDeaths.svg('get');
	});

	Loader.interceptFunction(TankTrouble.TankInfoBox, 'show', (original, ...args) => {
		original(...args);

		TankTrouble.TankInfoBox.infoDeathsDiv.tooltipster('content', 'Deaths');
		TankTrouble.TankInfoBox.infoDeathsSvg.clear();

		const [,, playerId] = args;

		Backend.getInstance().getPlayerDetails(result => {
			const deaths = typeof result === 'object' ? result.getDeaths() : 'N/A';

			const deathsText = TankTrouble.TankInfoBox.infoDeathsSvg.text(1, 22, deaths.toString(), {
				textAnchor: 'start',
				fontFamily: 'Arial Black',
				fontSize: 14,
				fill: 'white',
				stroke: 'black',
				strokeLineJoin: 'round',
				strokeWidth: 3,
				letterSpacing: 1,
				paintOrder: 'stroke'
			});
			const deathsLength = Utils.measureSVGText(deaths.toString(), {
				fontFamily: 'Arial Black',
				fontSize: 14
			});

			scaleAndTranslate = Utils.getSVGScaleAndTranslateToFit(UIConstants.TANK_INFO_MAX_NUMBER_WIDTH, deathsLength + 7, 34, 'left');
			TankTrouble.TankInfoBox.infoDeathsSvg.configure(deathsText, { transform: scaleAndTranslate });
		}, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache());
	});
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址