// ==UserScript==
// @name UX Improvements
// @author commander
// @description Many refactors and additions to the TankTrouble interface
// @namespace https://github.com/asger-finding/tanktrouble-userscripts
// @version 0.0.4
// @license GPL-3.0
// @match https://tanktrouble.com/*
// @match https://beta.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: Minimum game quality setting
// TODO: Lobby games carousel instead
// TODO: Controls switcher
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.ceil(delta), key);
}
}
return 'now';
};
GM_addStyle(`
player-name {
width: 150px;
height: 20px;
left: -5px;
top: -12px;
position: relative;
display: block;
}`);
// Wide "premium" screen
GM_addStyle(`
#content {
max-width: 1884px !important;
width: calc(100%) !important;
}
.horizontalAdSlot,
.verticalAdSlot,
#leftBanner,
#rightBanner,
#topBanner {
display: none !important;
}
`);
if (!customElements.get('player-name')) {
customElements.define('player-name',
/**
* Custom HTML element that renders a TankTrouble-style player name
* from the username, width and height attribute
*/
class PlayerName extends HTMLElement {
/**
* Initialize the player name element
*/
constructor() {
super();
const shadow = this.attachShadow({ mode: 'closed' });
this.username = this.getAttribute('username') || 'Scrapped';
this.width = this.getAttribute('width') || '150';
this.height = this.getAttribute('height') || '25';
// create the internal implementation
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', this.width);
this.svg.setAttribute('height', this.height);
this.name = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.name.setAttribute('x', '50%');
this.name.setAttribute('y', '0');
this.name.setAttribute('text-anchor', 'middle');
this.name.setAttribute('dominant-baseline', 'text-before-edge');
this.name.setAttribute('font-family', 'TankTrouble');
this.name.setAttribute('font-weight', 'normal');
this.name.setAttribute('font-size', '16');
this.name.setAttribute('fill', 'white');
this.name.setAttribute('stroke', 'black');
this.name.setAttribute('stroke-line-join', 'round');
this.name.setAttribute('stroke-width', '2');
this.name.setAttribute('paint-order', 'stroke');
this.name.textContent = this.username;
this.svg.appendChild(this.name);
shadow.appendChild(this.svg);
}
/**
* Scale the username SVG text when it's in the DOM.
*
* Bounding boxes will first be calculated right when
* it can be rendered.
*/
connectedCallback() {
const nameWidth = this.name.getComputedTextLength();
if (nameWidth > this.width) {
// Scale text down to match svg size
const newSize = Math.floor((this.width / nameWidth) * 100);
this.name.setAttribute('font-size', `${ newSize }%`);
}
}
});
}
(() => {
/**
* Patch a sprite that doesn't have a .log bound to it
* @param spriteName Name of the sprite in the DOM
* @returns Function wrapper
*/
const bindLogToSprite = spriteName => {
const Sprite = Reflect.get(unsafeWindow, spriteName);
if (!Sprite) throw new Error('No sprite in window with name', spriteName);
return function(...args) {
const sprite = new Sprite(...args);
sprite.log = Log.create(spriteName);
return sprite;
};
};
Reflect.set(unsafeWindow, 'UIDiamondSprite', bindLogToSprite('UIDiamondSprite'));
Reflect.set(unsafeWindow, 'UIGoldSprite', bindLogToSprite('UIGoldSprite'));
})();
(() => {
GM_addStyle(`
@keyframes highlight-thread {
50% {
border: #a0e900 2px solid;
background-color: #dcffcc;
}
}
.forum .thread.highlight .bubble,
.forum .reply.highlight .bubble {
animation: .5s ease-in 0.3s 2 alternate highlight-thread;
}
.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,
.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 username = typeof result === 'object' ? Utils.maskUnapprovedUsername(result) : 'Scrapped';
const width = UIConstants.TANK_ICON_RESOLUTIONS[UIConstants.TANK_ICON_SIZES.SMALL] + 10;
const height = 25;
const playerName = $(`<player-name username="${ username }" width="${ width }" height="${ height }"></player-name>`);
creatorsContainer.find('.tank.creator').append(playerName);
}, () => {}, () => {}, creators.creator, Caches.getPlayerDetailsCache());
};
/**
* Scroll a post into view if it's not already
* and highlight it once in view
* @param threadOrReply Parsed post element
*/
const highlightThreadOrReply = threadOrReply => {
const observer = new IntersectionObserver(entries => {
const [entry] = entries;
const inView = entry.isIntersecting;
threadOrReply[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
if (inView) {
threadOrReply.addClass('highlight');
observer.disconnect();
}
});
observer.observe(threadOrReply[0]);
};
/**
* 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 addShare = (threadOrReply, threadOrReplyElement) => {
const isReply = Boolean(threadOrReply.threadId);
const url = new URL(window.location.href);
const wasWindowOpenedFromPostShare = url.searchParams.get('ref') === 'share';
if (wasWindowOpenedFromPostShare && isReply) {
const urlReplyId = Number(url.searchParams.get('id'));
if (urlReplyId === threadOrReply.id) highlightThreadOrReply(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 link to clipboard');
}
});
shareAction.tooltipster('content', 'Copy link to clipboard');
shareAction.on('mouseup', () => {
const urlConstruct = new URL('/forum', window.location.origin);
if (isReply) {
urlConstruct.searchParams.set('id', threadOrReply.id);
urlConstruct.searchParams.set('threadId', threadOrReply.threadId);
} else {
urlConstruct.searchParams.set('threadId', threadOrReply.id);
}
urlConstruct.searchParams.set('ref', 'share');
ClipboardManager.copy(urlConstruct.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()}`
: '';
// We remake creation time since the timeAgo
// function estimates months slightly off
// which may result in instances where the
// edited happened longer ago than the thread
// creation date
const createdAgo = timeAgo(new Date(created * 1000));
const editedAgo = `, edited ${ timeAgo(new Date(latestEdit * 1000)) }`;
details.text(`Created ${createdAgo}${editedAgo}${lastReply}`);
}
};
/**
* Add anchor tags to links in posts
* @param _threadOrReply Post data
* @param threadOrReplyElement Parsed post element
*/
const addHyperlinks = (_threadOrReply, threadOrReplyElement) => {
const threadOrReplyContent = threadOrReplyElement.find('.bubble .content');
if (threadOrReplyContent.length) {
const urlRegex = /(?<_>https?:\/\/[\w\-_]+(?:\.[\w\-_]+)+(?:[\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?)/gu;
const messageWithLinks = threadOrReplyContent.html().replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
threadOrReplyContent.html(messageWithLinks);
}
};
/**
* Add extra features to a thread or reply
* @param threadOrReply Post data
* @param threadOrReplyElement
*/
const addFeaturesToThreadOrReply = (threadOrReply, threadOrReplyElement) => {
insertMultipleCreators(threadOrReply, threadOrReplyElement);
addLastEdited(threadOrReply, threadOrReplyElement);
addShare(threadOrReply, threadOrReplyElement);
addHyperlinks(threadOrReply, threadOrReplyElement);
};
/**
*
* @param threadOrReply
*/
const handleThreadOrReply = threadOrReply => {
if (threadOrReply === null) return;
const [key] = Object.keys(threadOrReply.html);
const html = threadOrReply.html[key];
if (typeof html === 'string') {
const threadOrReplyElement = $($.parseHTML(html));
addFeaturesToThreadOrReply(threadOrReply, threadOrReplyElement);
threadOrReply.html[key] = threadOrReplyElement;
threadOrReply.html.backup = html;
} else if (html instanceof $) {
// For some reason, the post breaks if it's already
// been parsed through here. Therefore, we pull
// from the backup html we set, and re-apply the changes
const threadOrReplyElement = $($.parseHTML(threadOrReply.html.backup));
addFeaturesToThreadOrReply(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) handleThreadOrReply(thread);
const result = threadListChanged.apply(this, [threadList, ...args]);
return result;
});
const replyListChanged = ForumView.getMethod('replyListChanged');
ForumView.method('replyListChanged', function(...args) {
const replyList = args.shift();
for (const thread of replyList) handleThreadOrReply(thread);
const result = replyListChanged.apply(this, [replyList, ...args]);
return result;
});
const getSelectedThread = ForumModel.getMethod('getSelectedThread');
ForumModel.method('getSelectedThread', function(...args) {
const result = getSelectedThread.apply(this, [...args]);
handleThreadOrReply(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());
});
})();