// ==UserScript==
// @name Internet Roadtrip - Look Out the Window v1
// @description Allows you rotate your view 90 degrees and zoom in on neal.fun/internet-roadtrip
// @namespace me.netux.site/user-scripts/internet-roadtrip/look-out-the-window-v1
// @version 1.14.0
// @author Netux
// @license MIT
// @match https://neal.fun/internet-roadtrip/
// @icon https://neal.fun/favicons/internet-roadtrip.png
// @grant GM.setValues
// @grant GM.getValues
// @grant GM.registerMenuCommand
// @run-at document-end
// @require https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/[email protected]
// @require https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==
(async () => {
const LEGACY_LOCAL_STORAGE_KEY = "internet-roadtrip/mod/look-out-the-window";
const DEFAULT_FRONT_OVERLAY_IMAGE = {
imageSrc: null
};
const DEFAULT_SIDE_OVERLAY_IMAGE = {
imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
transformOrigin: {
x: "50%",
y: "40%"
}
};
const DEFAULT_BACK_OVERLAY_IMAGE = {
imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/back%20window.png`,
transformOrigin: {
x: "50%",
y: "20%"
}
};
const Direction = Object.freeze({
FRONT: 0,
RIGHT: 1,
BACK: 2,
LEFT: 3
});
const state = {
settings: {
lookingDirection: Direction.FRONT,
zoom: 1,
showVehicleUi: true,
alwaysShowGameUi: false,
frontOverlay: DEFAULT_FRONT_OVERLAY_IMAGE,
sideOverlay: DEFAULT_SIDE_OVERLAY_IMAGE,
backOverlay: DEFAULT_BACK_OVERLAY_IMAGE
},
dom: {}
};
{
// migrate locals storage data form versions <=1.12.0
if (LEGACY_LOCAL_STORAGE_KEY in localStorage) {
const localStorageSettings = JSON.parse(localStorage.getItem(LEGACY_LOCAL_STORAGE_KEY));
await GM.setValues(localStorageSettings);
localStorage.removeItem(LEGACY_LOCAL_STORAGE_KEY);
}
}
{
const storedSettings = await GM.getValues(Object.keys(state.settings))
Object.assign(
state.settings,
storedSettings
);
}
function setupDom() {
injectStylesheet();
preloadOverlayImages();
const containerEl = document.querySelector('.container');
state.dom.containerEl = containerEl;
state.dom.panoIframeEls = Array.from(containerEl.querySelectorAll('.pano'));
state.dom.windowEl = document.createElement('div');
state.dom.windowEl.className = 'window';
state.dom.panoIframeEls.at(-1).insertAdjacentElement('afterend', state.dom.windowEl);
async function lookRight() {
state.settings.lookingDirection = (state.settings.lookingDirection + 1) % 4;
updateLookAt();
await saveSettings();
}
async function lookLeft() {
state.settings.lookingDirection = state.settings.lookingDirection - 1;
if (state.settings.lookingDirection < 0) {
state.settings.lookingDirection = 3;
}
updateLookAt();
await saveSettings();
}
function chevronImage(rotation) {
const imgEl = document.createElement('img');
imgEl.src = '/sell-sell-sell/arrow.svg'; // yoink
imgEl.style.width = `10px`;
imgEl.style.aspectRatio = `1`;
imgEl.style.filter = `invert(1)`;
imgEl.style.rotate = `${rotation}deg`;
return imgEl;
}
state.dom.lookLeftButtonEl = document.createElement('button');
state.dom.lookLeftButtonEl.className = 'look-left-btn';
state.dom.lookLeftButtonEl.appendChild(chevronImage(90));
state.dom.lookLeftButtonEl.addEventListener('click', lookLeft);
containerEl.appendChild(state.dom.lookLeftButtonEl);
state.dom.lookRightButtonEl = document.createElement('button');
state.dom.lookRightButtonEl.className = 'look-right-btn';
state.dom.lookRightButtonEl.appendChild(chevronImage(-90));
state.dom.lookRightButtonEl.addEventListener('click', lookRight);
containerEl.appendChild(state.dom.lookRightButtonEl);
window.addEventListener("keydown", async (event) => {
if (event.target !== document.body) {
return;
}
switch (event.key) {
case "ArrowLeft": {
await lookLeft();
break;
}
case "ArrowRight": {
await lookRight();
break;
}
}
});
window.addEventListener("wheel", async (event) => {
if (event.target !== document.documentElement) { // pointing at nothing but the backdrop
return;
}
const scrollingForward = event.deltaY < 0;
state.settings.zoom = Math.min(Math.max(1, state.settings.zoom * (scrollingForward ? 1.1 : 0.9)), 20);
updateZoom();
await saveSettings();
})
updateUiFromSettings();
updateOverlays();
updateLookAt();
updateZoom();
}
function injectStylesheet() {
const styleEl = document.createElement('style');
styleEl.innerText = `
body {
& .look-right-btn, & .look-left-btn {
position: fixed;
bottom: 200px;
transform: translateY(-50%);
padding-block: 1.5rem;
border: none;
background-color: whitesmoke;
cursor: pointer;
}
& .look-right-btn {
right: 0;
padding-inline: 0.35rem 0.125rem;
border-radius: 15px 0 0 15px;
}
& .look-left-btn {
left: 0;
padding-inline: 0.125rem 0.25rem;
border-radius: 0 15px 15px 0;
}
&:not(.look-out-the-window-always-show-game-ui):not([data-look-out-the-window-direction="${Direction.FRONT}"]) :is(.freshener-container, .wheel-container, .options) {
display: none;
}
& .window {
position: fixed;
width: 100%;
background-size: cover;
height: 100%;
background-position: center;
pointer-events: none;
display: none;
}
&[data-look-out-the-window-direction="${Direction.FRONT}"] .window {
transform-origin: var(--look-out-the-window-front-overlay-transform-origin);
background-image: var(--look-out-the-window-front-overlay-image-src);
}
&[data-look-out-the-window-direction="${Direction.LEFT}"] .window,
&[data-look-out-the-window-direction="${Direction.RIGHT}"] .window {
transform-origin: var(--look-out-the-window-side-overlay-transform-origin);
background-image: var(--look-out-the-window-side-overlay-image-src);
}
&[data-look-out-the-window-direction="${Direction.RIGHT}"] .window {
rotate: y 180deg;
}
&[data-look-out-the-window-direction="${Direction.BACK}"] .window {
transform-origin: var(--look-out-the-window-back-overlay-transform-origin);
background-image: var(--look-out-the-window-back-overlay-image-src);
}
&.look-out-the-window-show-vehicle-ui .window {
display: initial;
}
& .pano, & window {
transition: opacity 300ms linear, scale 100ms linear;
}
}
`;
document.head.appendChild(styleEl);
}
function preloadOverlayImages() {
const configuredOverlayImagesSources = [state.settings.frontOverlay, state.settings.sideOverlay, state.settings.backOverlay]
.map((overlay) => overlay?.imageSrc)
.filter((imageSrc) => !!imageSrc);
for (const imageSrc of configuredOverlayImagesSources) {
const image = new Image();
image.onload = () => {
console.debug(`Successfully preloaded Look Out the Window image at "${imageSrc}"`);
};
image.onerror = (event) => {
console.error(`Failed to preload Look Out the Window image at "${imageSrc}"`, event);
};
image.src = imageSrc;
}
}
function patch(vue) {
function replaceHeadingInPanoUrl(urlStr, headingOverride = null) {
if (!urlStr) {
return urlStr;
}
const url = new URL(urlStr);
const currentHeading = headingOverride ?? parseFloat(url.searchParams.get('heading'));
url.searchParams.set('heading', (currentHeading + state.settings.lookingDirection * 90) % 360);
return url.toString();
}
vue.state.getPanoUrl = new Proxy(vue.methods.getPanoUrl, {
apply(ogGetPanoUrl, thisArg, args) {
const urlStr = ogGetPanoUrl.apply(thisArg, args);
return replaceHeadingInPanoUrl(urlStr);
}
});
const panoEls = Object.keys(vue.$refs).filter((name) => name.startsWith('pano')).map((key) => vue.$refs[key]);
state.transitionPano = (animate = true) => {
const now = Date.now();
const currFrame = vue.state.currFrame;
const nextFrame = (currFrame + 1) % panoEls.length;
const activePanoEl = panoEls[currFrame];
const transitionPanoEl = panoEls[nextFrame];
const timeUntilVanillaTransition = state.vue.state.endTime - now;
const isVanillaTransitioning = timeUntilVanillaTransition <= 0;
if (isVanillaTransitioning) {
// The page will do the transition for us
transitionPanoEl.src = replaceHeadingInPanoUrl(activePanoEl.src);
return;
}
if (animate) {
state.vue.state.currFrame = nextFrame;
transitionPanoEl.src = replaceHeadingInPanoUrl(activePanoEl.src, state.vue.data.currentHeading);
const animationDuration = 500;
setTimeout(() => {
state.vue.methods.switchFrameOrder();
}, 500);
} else {
activePanoEl.src = replaceHeadingInPanoUrl(activePanoEl.src, state.vue.data.currentHeading);
}
};
}
function updateUiFromSettings() {
document.body.classList.toggle('look-out-the-window-show-vehicle-ui', state.settings.showVehicleUi);
document.body.classList.toggle('look-out-the-window-always-show-game-ui', state.settings.alwaysShowGameUi);
}
function updateOverlays() {
const setCssVariable = (element, name, value) => value ? element.style.setProperty(`--${name}`, value) : element.style.removeProperty(`--${name}`);
for (const overlayName of ['front', 'side', 'back']) {
const overlay = state.settings[`${overlayName}Overlay`];
const cssVariable = (name) => `look-out-the-window-${overlayName}-overlay-${name}`;
setCssVariable(
state.dom.windowEl, cssVariable('image-src'),
`url("${overlay.imageSrc}")`
);
setCssVariable(
state.dom.windowEl, cssVariable('transform-origin'),
overlay.transformOrigin
? `${overlay.transformOrigin.x} ${overlay.transformOrigin.y}`
: null
);
}
}
function updateLookAt(animate = true) {
document.body.dataset.lookOutTheWindowDirection = state.settings.lookingDirection;
state.transitionPano(animate);
}
function updateZoom() {
for (const panoIframeEl of state.dom.panoIframeEls) {
panoIframeEl.style.scale = (state.settings.zoom * 0.4 + 0.6 /* parallax */).toString();
}
state.dom.windowEl.style.scale = state.settings.zoom.toString();
}
async function saveSettings() {
await GM.setValues(state.settings);
}
GM.registerMenuCommand('Toggle Vehicle UI', async () => {
state.settings.showVehicleUi = !state.settings.showVehicleUi;
updateUiFromSettings();
await saveSettings();
}, { id: 'look-out-the-window-toggle-vehicle-ui' });
GM.registerMenuCommand('Toggle Always show Game UI', async () => {
state.settings.alwaysShowGameUi = !state.settings.alwaysShowGameUi;
updateUiFromSettings();
await saveSettings();
}, { id: 'look-out-the-window-toggle-always-show-game-ui' });
state.vue = await IRF.vdom.container;
patch(state.vue);
setupDom();
})();