[PS] Battle Log Styler

Re-styles the Pokémon Showdown battle log.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        [PS] Battle Log Styler
// @namespace   https://greasyfork.org/en/users/1357767-indigeau
// @version     0.0
// @description Re-styles the Pokémon Showdown battle log.
// @match       https://play.pokemonshowdown.com/*
// @exclude     https://play.pokemonshowdown.com/sprites/*
// @author      indigeau
// @license     GNU GPLv3
// @icon        https://www.google.com/s2/favicons?sz=64&domain=pokemonshowdown.com
// @grant       none
// ==/UserScript==

const main = () => {
	const logSelector = '.battle-log > .message-log';
	
	(() => {
		const styleSheet = (() => {
			const styleElement = document.createElement('style');
			
			document.head.appendChild(styleElement);
			
			return styleElement.sheet;
		})();
		
		const rules = [
			[`${logSelector} > .spacer.battle-history`, [
				['width', '100%'],
				['height', '2px'],
			]],
			[`${logSelector} > .spacer.battle-history:not(h2 + *):not(:has(+h2))`, [
				['margin', '8px -8px'],
				['padding', '0 8px'],
			]],
			[`${logSelector} > h2.battle-history`, [['border-width', '2px']]],
			[`${logSelector} > :not(.battle-history):not(.chat)`, [
				['margin', '-8px'],
				['padding', '5px 8px'],
			]],
			[`${logSelector} > .chat:first-child`, [['visibility', 'hidden']]],
			['.battle-log > .battle-options:has(+ .message-log > .chat:first-child)', [['margin-bottom', '-1em']]],
			['html.dark .ps-room .battle-log', [['scrollbar-color', '#333 #5a5a5a']]],
			['html:not(.dark) .ps-room .battle-log', [['scrollbar-color', '#eef2f5 #aaa']]],
			[`html:not(.dark) ${logSelector} > .spacer.battle-history:not(h2 + *):not(:has(+h2))`, [['background', '#aaa']]],
			[`html.dark ${logSelector} > .spacer.battle-history:not(h2 + *):not(:has(+h2))`, [['background', '#5a5a5a']]],
		];
		
		for (let rule of rules) {
			styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`);
		}
	})();
	
	(() => {
		const onBattle = (() => {
			const addListeners = [];
			const removeListeners = [];
			
			const getBattleRoom = (node) => {
				if (!node.classList.contains('ps-room')) {
					return null;
				}
				
				const room = window.app.rooms[node.id.slice(5)];
				
				return room instanceof window.BattleRoom ? room : null;
			};
			
			const rooms = [...document.body.children]
				.map((node) => getBattleRoom(node))
				.filter((room) => room !== null);
			
			(new MutationObserver((mutations) => {
				for (const {addedNodes, removedNodes} of mutations) {
					for (const node of addedNodes) {
						const room = getBattleRoom(node);
						
						if (!room) {
							return;
						}
						
						for (const listener of addListeners) {
							listener(room, node.id);
						}
						
						rooms.push(room);
					}
					
					for (const node of removedNodes) {
						for (const listener of removeListeners) {
							listener(node.id);
						}
					}
				}
			})).observe(document.body, {childList: true});
			
			return (onAdd, onRemove) => {
				addListeners.push(onAdd);
				
				if (onRemove) {
					removeListeners.push(onRemove);
				}
				
				for (const room of rooms) {
					onAdd(room, room.el.id);
				}
			};
		})();
		
		// Fixes scroll issue for leave message
		onBattle(({'$chat': [{parentElement, children}], 'userList': {'el': userList}}) => {
			(new MutationObserver(() => {
				if (children.length === 0) {
					return;
				}
				
				const {offsetTop} = children[children.length - 2];
				
				if (parentElement.scrollTop + parentElement.clientHeight >= offsetTop) {
					parentElement.scrollTop = parentElement.scrollHeight;
				}
			})).observe(userList, {childList: true});
		});
		
		// Header colour setting
		
		// HSL without the H
		const SL = {
			LIGHT: [24, 90],
			DARK: [20, 20],
		};
		
		// https://stackoverflow.com/a/6445104
		function rgbToHue(...args) {
			const [r, g, b] = args.map((arg) => arg / 255);
			const max = Math.max(r, g, b);
			const min = Math.min(r, g, b);
			
			if (max === min) {
				return 0;
			}
			
			const d = max - min;
			
			switch (max) {
				case r:
					return ((g - b) / d + (g < b ? 6 : 0)) / 6 * 255;
				
				case g:
					return ((b - r) / d + 2) / 6 * 255;
			}
			
			return ((r - g) / d + 4) / 6 * 255;
		}
		
		const styleSheets = {};
		
		onBattle(
			({battle}, id) => {
				const [deleteColours, setColours] = (() => {
					const styleSheet = (() => {
						const styleElement = document.createElement('style');
						
						document.head.appendChild(styleElement);
						
						return styleElement.sheet;
					})();
					
					const selectors = [`#${id} ${logSelector} > :not(.battle-history):not(.chat)`, `#${id} ${logSelector} > h2.battle-history`];
					
					styleSheets[id] = styleSheet;
					
					return [
						(deleteInitial = false) => {
							for (let i = styleSheet.cssRules.length - 1; i >= (deleteInitial ? 0 : 2); --i) {
								styleSheet.deleteRule(i);
							}
						},
						(hue, doTransition = true) => {
							const rules = [
								[selectors.map((selector) => `html:not(.dark) > body > ${selector}`).toString(), [
									['background-color', `hsl(${hue}, ${SL.LIGHT[0]}%, ${SL.LIGHT[1]}%)${doTransition ? ' !important' : ''}`],
								]],
								[selectors.map((selector) => `html.dark > body > ${selector}`).toString(), [
									['background-color', `hsl(${hue}, ${SL.DARK[0]}%, ${SL.DARK[1]}%)${doTransition ? ' !important' : ''}`],
								]],
							];
							
							if (doTransition) {
								rules.push([selectors.toString(), [['transition', 'background-color 0.9s ease-in-out']]]);
							}
							
							for (let rule of rules) {
								styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value};`).join('')}}`, styleSheet.cssRules.length);
							}
						},
					];
				})();
				
				const init = async () => {
					const image = new Image();
					
					await new Promise((resolve) => {
						image.onload = resolve;
						
						image.src = battle.scene.backdropImage;
					});
					
					const baseHue = rgbToHue(...(new window.ColorThief()).getColor(image));
					
					const updateWeather = (doTransition = false) => {
						const [r, g, b, a = 1] = [...getComputedStyle(battle.scene.$weather[0]).backgroundColor]
							.filter((c) => /[\d\s]/.test(c))
							.join('')
							.split(' ')
							.map((string) => Number.parseInt(string));
						
						deleteColours();
						
						if (a === 0) {
							return;
						}
						
						const weatherHue = rgbToHue(r, g, b);
						
						setColours(weatherHue, doTransition);
					};
					
					setColours(baseHue, false);
					
					if (battle.weather) {
						updateWeather();
					}
					
					Object.defineProperty(battle, 'weather', (() => {
						let weather = battle.weather;
						
						return {
							set(value) {
								weather = value;
								
								updateWeather(true);
							},
							get() {
								return weather;
							},
						};
					})());
				};
				
				init();
				
				Object.defineProperty(battle.scene, 'backdropImage', (() => {
					let image = battle.scene.backdropImage;
					
					return {
						set(value) {
							image = value;
							
							deleteColours(true);
							
							init();
						},
						get() {
							return image;
						},
					};
				})());
			},
			(id) => {
				if (!(id in styleSheets)) {
					return;
				}
				
				styleSheets[id].node.remove();
				
				delete styleSheets[id];
			},
		);
		
		Object.defineProperty(Storage.prefs.data, 'theme', (() => {
			let theme = Storage.prefs.data.theme;
			
			return {
				set(value) {
					theme = value;
					
					for (const sheet of Object.values(styleSheets)) {
						if (sheet.cssRules.length > 4) {
							sheet.deleteRule(4);
						}
					}
				},
				get() {
					return theme;
				},
			};
		})());
	})();
};

// Dealing with firefox being restrictive
(() => {
	const context = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
	
	context._test = {};
	
	const isAccessDenied = (() => {
		try {
			window.eval('_test._');
		} catch (e) {
			return true;
		}
		
		return false;
	})();
	
	delete context._test;
	
	if (isAccessDenied) {
		window.eval(`(${main.toString()})()`);
	} else {
		main();
	}
})();