// ==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());
});
})();