您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows you rotate your view 90 degrees and zoom in on neal.fun/internet-roadtrip
当前为
// ==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.16.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-start // @require https://cdn.jsdelivr.net/npm/[email protected] // ==/UserScript== (async () => { const CSS_PREFIX = 'lotwv1-'; 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 ); } const cssClass = (names) => (Array.isArray(names) ? names : [names]).map((name) => `${CSS_PREFIX}${name}`).join(' '); 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 = cssClass('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 = cssClass('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 = cssClass('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(); }); const settingsTab = IRF.ui.panel.createTabFor(GM.info, { tabName: 'LOtW', style: ` .${cssClass('settings-tab-content')} { & .${cssClass('field-group')} { margin: 0.5rem; display: flex; justify-content: space-between; & input[type="checkbox"] { vertical-align: middle; } } } `, className: cssClass('settings-tab-content') }); renderSettings(settingsTab.container); updateUiFromSettings(); updateOverlays(); updateLookAt(); updateZoom(); } function injectStylesheet() { const styleEl = document.createElement('style'); styleEl.innerText = ` body { & .${cssClass('look-right-btn')}, & .${cssClass('look-left-btn')} { position: fixed; bottom: 200px; transform: translateY(-50%); padding-block: 1.5rem; border: none; background-color: whitesmoke; cursor: pointer; } & .${cssClass('look-right-btn')} { right: 0; padding-inline: 0.35rem 0.125rem; border-radius: 15px 0 0 15px; } & .${cssClass('look-left-btn')} { left: 0; padding-inline: 0.125rem 0.25rem; border-radius: 0 15px 15px 0; } &:not(.${cssClass('always-show-game-ui')}):not([data-look-out-the-window-direction="${Direction.FRONT}"]) :is(.freshener-container, .wheel-container, .options) { display: none; } & .${cssClass('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}"] .${cssClass('window')} { transform-origin: var(--${CSS_PREFIX}front-overlay-transform-origin); background-image: var(--${CSS_PREFIX}front-overlay-image-src); } &[data-look-out-the-window-direction="${Direction.LEFT}"] .${cssClass('window')}, &[data-look-out-the-window-direction="${Direction.RIGHT}"] .${cssClass('window')} { transform-origin: var(--${CSS_PREFIX}side-overlay-transform-origin); background-image: var(--${CSS_PREFIX}side-overlay-image-src); } &[data-look-out-the-window-direction="${Direction.RIGHT}"] .${cssClass('window')} { rotate: y 180deg; } &[data-look-out-the-window-direction="${Direction.BACK}"] .${cssClass('window')} { transform-origin: var(--${CSS_PREFIX}back-overlay-transform-origin); background-image: var(--${CSS_PREFIX}back-overlay-image-src); } &.${cssClass('show-vehicle-ui')} .${cssClass('window')} { display: initial; } & .pano, & .${cssClass('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 renderSettings(containerEl) { function makeFieldGroup(id, label, renderInput) { const fieldGroupContainerEl = document.createElement('div'); fieldGroupContainerEl.className = cssClass('field-group'); containerEl.appendChild(fieldGroupContainerEl); const labelEl = document.createElement('label'); labelEl.for = id; labelEl.textContent = label; fieldGroupContainerEl.appendChild(labelEl); const inputEl = renderInput({ id }); fieldGroupContainerEl.appendChild(inputEl); } makeFieldGroup(`${CSS_PREFIX}toggle-vehicle-overlay`, 'Show Vehicle Overlay', ({ id }) => { state.dom.toggleVehicleOverlayInputEl = document.createElement('input'); state.dom.toggleVehicleOverlayInputEl.id = id; state.dom.toggleVehicleOverlayInputEl.type = 'checkbox'; state.dom.toggleVehicleOverlayInputEl.className = IRF.ui.panel.styles.toggle; state.dom.toggleVehicleOverlayInputEl.addEventListener('change', async () => { state.settings.showVehicleUi = state.dom.toggleVehicleOverlayInputEl.checked; await saveSettings(); updateUiFromSettings(); }); return state.dom.toggleVehicleOverlayInputEl; }); makeFieldGroup(`${CSS_PREFIX}always-show-game-ui`, 'Always show Game UI', ({ id }) => { state.dom.alwaysShowGameUIInputEl = document.createElement('input'); state.dom.alwaysShowGameUIInputEl.id = id; state.dom.alwaysShowGameUIInputEl.type = 'checkbox'; state.dom.alwaysShowGameUIInputEl.className = IRF.ui.panel.styles.toggle; state.dom.alwaysShowGameUIInputEl.addEventListener('change', async () => { state.settings.alwaysShowGameUi = state.dom.alwaysShowGameUIInputEl.checked; await saveSettings(); updateUiFromSettings(); }); return state.dom.alwaysShowGameUIInputEl; }); } function patch(vue) { const calculateOverridenHeadingAngle = (baseHeading) => (baseHeading + state.settings.lookingDirection * 90) % 360; function replaceHeadingInPanoUrl(urlStr, vanillaHeadingOverride = null) { if (!urlStr) { return urlStr; } const url = new URL(urlStr); if (vanillaHeadingOverride != null || url.searchParams.has('heading')) { const currentHeading = vanillaHeadingOverride ?? parseFloat(url.searchParams.get('heading')); if (!isNaN(currentHeading)) { url.searchParams.set('heading', calculateOverridenHeadingAngle(currentHeading)); } } 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]); let isVanillaTransitioning = false; { /** * For reference, this is what the vanilla code more-or-less does: * * ```js * function changeStop(..., newPano, newHeading, ...) { * // ... * this.currFrame = this.currFrame === 0 ? 1 : 0; * this.currentPano = newPano; * // ... * setTimeout(() => { * this.switchFrameOrder(); * this.currentHeading = newHeading; * // ... * }, someDelay)); * } * ``` * * Note the heading is set with a delay, after switchFrameOrder is called. */ vue.state.changeStop = new Proxy(vue.methods.changeStop, { apply(ogChangeStop, thisArg, args) { isVanillaTransitioning = true; return ogChangeStop.apply(thisArg, args); } }); function isCurrentFrameFacingTheCorrectDirection() { const currPanoSrc = panoEls[vue.state.currFrame]?.src; const currPanoUrl = currPanoSrc && new URL(currPanoSrc); if (!currPanoUrl) { return false; } const urlHeading = parseFloat(currPanoUrl.searchParams.get('heading')); if (isNaN(urlHeading)) { return false; } const correctHeading = calculateOverridenHeadingAngle(state.vue.data.currentHeading); return Math.abs(urlHeading - correctHeading) < 1e-3; } vue.state.switchFrameOrder = new Proxy(vue.methods.switchFrameOrder, { apply(ogSwitchFrameOrder, thisArg, args) { isVanillaTransitioning = false; requestIdleCallback(() => { // run after currentHeading is updated (see reference method implementation above) if (!isCurrentFrameFacingTheCorrectDirection()) { attemptManualPanoTransition(/* animate: */ true); } }); return ogSwitchFrameOrder.apply(thisArg, args); } }); } let modTransitionTimeout = null; function attemptManualPanoTransition(animate = true) { const now = Date.now(); const currFrame = vue.state.currFrame; const nextFrame = (currFrame + 1) % panoEls.length; const activePanoEl = panoEls[currFrame]; const attemptManualPanoTransitionEl = panoEls[nextFrame]; if (!activePanoEl.src) { // The vanilla code hasn't set a src on the current pano iframe yet, meaning this ran too soon. // We'll let the vanilla code do the transition for us. clearTimeout(modTransitionTimeout); return; } if (isVanillaTransitioning) { // The page will do the transition for us clearTimeout(modTransitionTimeout); return; } const newPanoUrl = replaceHeadingInPanoUrl(activePanoEl.src, state.vue.data.currentHeading); if (animate) { if (modTransitionTimeout == null) { state.vue.state.currFrame = nextFrame; attemptManualPanoTransitionEl.src = newPanoUrl; } else { clearTimeout(modTransitionTimeout); activePanoEl.src = newPanoUrl; } modTransitionTimeout = setTimeout(() => { modTransitionTimeout = null; state.vue.methods.switchFrameOrder(); }, 500); } else { activePanoEl.src = newPanoUrl; } }; state.attemptManualPanoTransition = attemptManualPanoTransition; } function updateUiFromSettings() { state.dom.toggleVehicleOverlayInputEl.checked = state.settings.showVehicleUi; document.body.classList.toggle(cssClass('show-vehicle-ui'), state.settings.showVehicleUi); state.dom.alwaysShowGameUIInputEl.checked = state.settings.alwaysShowGameUi; document.body.classList.toggle(cssClass('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) => `${CSS_PREFIX}${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.attemptManualPanoTransition(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); } state.vue = await IRF.vdom.container; patch(state.vue); setupDom(); saveSettings(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址