您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
This mod adds a handling editor for slowroads.io. Supports game version v1.0.2.
// ==UserScript== // @name Opinion's Handling Editor for slowroads.io // @namespace https://github.com/Opinion/slowroads-handling-editor // @version 1.3 // @description This mod adds a handling editor for slowroads.io. Supports game version v1.0.2. // @author Opinion // @match https://slowroads.io/ // @icon https://www.google.com/s2/favicons?sz=64&domain=slowroads.io // @grant none // @run-at document-start // ==/UserScript== /* globals unsafeWindow, window */ const Core = { settings: { supportedVersion: '1.0.2', originalGameScriptPattern: /https?:\/\/slowroads\.io\/static\/js\/main\.([a-z0-9]+)\.chunk\.js$/gm, originalGameScript: null, modifiedGameScript: { prefix: 'https://cdn.jsdelivr.net/gh/Opinion/slowroads-handling-editor/dist/main.modified.', suffix: '.chunk.js', identifier: null, get() { return `${this.prefix}${this.identifier}${this.suffix}` }, }, }, /** * Custom logger * * @param {...any} args Arguments you want to log */ log(...args) { console.log('[🔧]', '[OPINION]', ...args) }, } const WindowInteraction = { /** * Get window 'unsafeWindow' or 'window' * * To support Greasemonkey, we need to access 'window' through 'unsafeWindow'. * Details: https://wiki.greasespot.net/UnsafeWindow * * @returns {Window} */ getWindow() { return typeof unsafeWindow === 'object' ? unsafeWindow : window }, /** * Get Toastify through window * * @returns {Toastify|function|null} */ getToastify() { return this.getWindow()?.Toastify }, /** * Get 'exposedI' through window * * @returns {object|null} */ getExposedI() { return this.getWindow()?.exposedI }, /** * Get 'exposedVersion' through window * * @returns {string|null} */ getExposedVersion() { const version = this.getWindow()?.exposedVersion return typeof version === 'string' ? version : null }, } const DocumentInteraction = { /** * Append a <script> element to <head> * * @param {string} url URL for 'src' attribute */ appendScript(src) { const element = document.createElement('script') element.src = src document.head.append(element) Core.log('Appended script', { element, src }) }, /** * Append a <link> element to <head> * * @param {string} url URL for rel attribute */ appendStyle(src) { const element = document.createElement('link') element.href = src element.rel = 'stylesheet' document.head.append(element) Core.log('Appended style', { element, src }) }, /** * Append a <script> element to <body> with raw code * * Useful because we can de-elevate (is that a word?) code and let it run from the page. * Code will NOT run with the elevated permissions of the userscript. * * @param {string} code Javascript code */ appendRawScript(code) { const element = document.createElement('script') element.innerHTML = code document.body.append(element) Core.log('Appended raw script', { element, code }) }, /** * Get game's canvas element * * @returns {HTMLElement|null} */ getGameCanvasElement() { return document.querySelector('#game-main > canvas.render-canvas') }, } /** * Dependency Manager * * ## Brief overview * - Loaded state is stored in 'dependency.loaded' as a boolean. * - When a dependency has loaded, it will not load again on subsequent requests. * - Dependencies are stored in object keys so we can easily load necessary depedencies out-of-order. * - If a javascript or css dependency is a function, it will be executed. * - If the output of the function is a string, the string will be loaded as a dependency. * * ## Dependency structure * - dependency.name | {string} (Required) Name of the dependency (used in logs) * - dependency.js | {array|string[]|function[]|null|undefined} (Optional) Array of URLs to javascript files * - dependency.css | {array|string[]|function[]|null|undefined} (Optional) Array of URLs to CSS files * - dependency.isLoaded | {function|null|undefined} (Optional) A function to check if the dependency has loaded (for use outside the Dependency Manager) * - dependency.loaded | {boolean|null|undefined} (Automatic) Has this dependency been loaded yet? * * Note: 'dependency.loaded' is handled automatically. Please don't touch it. */ const DependencyManager = { dependencies: { toastify: { name: 'Toastify.js', js: [ 'https://cdn.jsdelivr.net/npm/[email protected]/src/toastify.min.js', ], css: [ 'https://cdn.jsdelivr.net/npm/[email protected]/src/toastify.min.css', ], isLoaded: () => typeof WindowInteraction.getToastify() === 'function', }, modifiedGameScript: { name: 'Modified game script', js: [ () => Core.settings.modifiedGameScript.get(), // Function -- Will be resolved when loaded. ], isLoaded: () => typeof WindowInteraction.getExposedI() === 'object', }, }, /** * Custom logger for dependencies * * @param {object} dependency * @param {...any} args */ log(dependency, ...args) { Core.log(`[Dependency: ${dependency.name}]`, ...args) }, internal: { /** * Resolve url * * A url can either be a string or a function. * If the url is a function, the function will be resolved and the output returned (as long as it is a string). * * @param {string|function|null|undefined} url * @returns {string|null} */ resolveUrl(url) { // Set output to whatever the url is let output = url // Check if the url is a function and resolve it if (typeof url === 'function') { output = url() } // Return the output if it is a string return typeof output === 'string' ? output : null }, }, /** * Load a dependency * * Appends necessary <script> and <link> elements to <head>. * When a dependency has loaded it will not load again. * * @param {object} dependency */ loadDependency(dependency) { if (typeof dependency !== 'object') { throw Error('Dependency must be an object.') } // Checking state if (dependency.loaded === true) { this.log(dependency, 'Already loaded. Ignoring.') return } this.log(dependency, 'Loading dependency...', { dependency: dependency }) // Append javascript URLs if (Array.isArray(dependency.js) && dependency.js.length) { for (let i = 0; i < dependency.js.length; i++) { // Resolve url const url = this.internal.resolveUrl(dependency.js[i]) // Append if the url was correctly resolved if (url !== null) { DocumentInteraction.appendScript(url) } } } // Append CSS URLs if (Array.isArray(dependency.css) && dependency.css.length) { for (let i = 0; i < dependency.css.length; i++) { // Resolve url const url = this.internal.resolveUrl(dependency.css[i]) // Append if the url was correctly resolved if (url !== null) { DocumentInteraction.appendStyle(url) } } } // Saving state dependency.loaded = true }, /** * Load dependencies * * @param {array|object[]|null} dependencies Custom dependencies. Use 'null' to use configured dependencies. */ load(dependencies = null) { // -- Attempt to use dependencies from parameter // Check if we can use dependecies from parameter if (Array.isArray(dependencies) && length) { // Load dependencies (from an array) for (let i = 0; i < dependencies.length; i++) { const dependency = dependencies[i] this.loadDependency(dependency) } return // -- Important } // -- Fallback to configured dependencies // Make sure dependencies are stored in an object if (typeof this.dependencies !== 'object') { throw Error('Expected \'this.dependencies\' to be an object.') } // Load dependencies for (const key in this.dependencies) { if (Object.hasOwnProperty.call(this.dependencies, key)) { const dependency = this.dependencies[key] this.loadDependency(dependency) } } }, } /** * Toastmaker * * It makes toasts (: */ const Toastmaker = { /** * Configured toast styles */ styles: { default: { background: 'linear-gradient(to right bottom, rgb(158, 168, 170), rgb(124, 147, 155))', cursor: 'initial', }, info: { background: 'linear-gradient(to bottom right, rgb(17, 130, 114), rgb(70, 110, 125))', cursor: 'initial', }, error: { background: 'linear-gradient(to right bottom, rgb(162, 51, 56), rgb(130, 6, 12))', cursor: 'initial', maxWidth: '800px', }, }, /** * Get toast style * * @param {string|null} style Name of a toast style * @returns {object} */ getStyle(style = null) { return this.styles[style] ? this.styles[style] : this.styles.default }, /** * Make a toast * * Passed through a script tag to let it execute from the page. * * @param {string} message Toast message * @param {string|null} style Name of a toast style * @param {number} duration Duration in milliseconds * @param {boolean} close Do you want a close button to be shown? */ makeToast(message, style = null, duration = 5000, close = true) { // Create toastify payload const payload = { text: message, duration: duration, escapeMarkup: false, close: close, gravity: 'top', position: 'center', stopOnFocus: 'true', style: this.getStyle(style), } // Turn payload into JSON so we can pass it through a raw script element const payloadAsJson = JSON.stringify(payload) DocumentInteraction.appendRawScript(` { // Greasemonkey doesn't like it when we call a function through 'unsafeWindow' // This script block is appended to the page and will not be executed with elevated permissions. // This payload was converted to JSON by the handling editor and is accessible as an object // without any changes const payload = ${payloadAsJson} // Since this code block runs from the page, we can access Toastify without going through 'window' Toastify(payload)?.showToast() }`) // end of multiline string }, } /** * Condition Checker * * ## Behavior * - Condition state is stored in 'condition.state'. * - When a condition fails, the loop will break and the condition will be checked again next time. * - When a condition passes, 'condition.state.passed' will be set to 'true'. * - Next loop, the condition will not be checked through 'condition.passes' because 'condition.state.passed' is set * to 'true'. * - Even if 'condition.state.passed' is 'true', 'condition.onPass' will be used ('condition.onPass' behaves like * normal). * - This descision might seem a little weird. It is needed to avoid an already-passing conditon to fail later. * - Example; Checking the game version requires a specific HTML element. The element is removed when you 'begin' the * game. * - TL;DR When a condition has passed, it will never fail during later checks. * * * * ## Condition structure * - condition.name | {string} (Required) Name of the condition. * - condition.passes | {function} (Required) Requirement to pass the condition. * - condition.beforeCheck | {object|null|undefined} (Optional) Properties documented below. * - condition.onPass | {object|null|undefined} (Optional) Properties documented below. * - condition.onFail | {object|null|undefined} (Optional) Properties documented below. * - condition.state | {object|null|undefined} (Automatic) Properties documented below. * * * ### Properties for 'beforeCheck', 'onPass' and 'onFail' * - message | {string|null|undefined} Message to be logged. * - messageOnce | {boolean|null|undefined} Do you want the message to be logged once? Defaults to 'true'. * - run | {function|null|undefined} A function you want to run. * - runOnce | {boolean|null|undefined} Do you want the run function to execute once? Defaults to 'true'. * - throwException | {boolean|null|undefined} Do you want to exit the script at this point (after executing 'run' * function)? Defaults to 'false'. * * Be careful with how you set up the 'run' function. In most cases you will want 'runOnce' to be 'true'. * * Remember: Conditions will run again if one fails. 'beforeCheck', 'onPass' and 'onFail' can happen many times before * all conditions have passed. * * * ### Properties for 'state' (handled automatically) * - passed | {boolean|null|undefined} Has the condition passed once? * - beforeCheckMessage | {boolean|null|undefined} Has 'beforeCheck.message' been logged once? * - beforeCheckRun | {boolean|null|undefined} Has 'beforeCheck.run' been executed once? * - onPassMessage | {boolean|null|undefined} Has 'onPass.message' been logged once? * - onPassRun | {boolean|null|undefined} Has 'onPass.run' been executed once? * - onFailMessage | {boolean|null|undefined} Has 'onFail.message' been logged once? * - onFailRun | {boolean|null|undefined} Has 'onFail.run' been executed once? * * Note: 'condition.state' is handled automatically. please don't define anything here. * * * * ## Full object example (with state) * ``` * const condition = { * name: 'Example condition', * passes: () => typeof 'Hello' === 'string', * beforeCheck: { * message: 'Logged before checking the condition.', * messageOnce: true, * run: () => console.log('I was executed at \'beforeCheck\'.'), * runOnce: true, * throwException: false, * }, * onPass: { * message: 'Logged after passing the condition.', * messageOnce: true, * run: () => console.log('I was executed at \'onPass\'.'), * runOnce: true, * throwException: false, * }, * onFail: { * message: 'Logged after failing the condition.', * messageOnce: true, * run: () => console.log('I was executed at \'onFail\'.'), * runOnce: true, * throwException: true, * }, * state: { * passed: false, * beforeCheckMessage: false, * beforeCheckRun: false, * onPassMessage: false, * onPassRun: false, * onFailMessage: false, * onFailRun: false, * }, * }; * ``` */ const ConditionChecker = { conditions: [ { name: 'Toastify.js', passes: () => DependencyManager.dependencies.toastify.isLoaded(), beforeCheck: { message: 'Waiting for Toastify.js to finish loading...', messageOnce: true, }, onPass: { message: 'Dependency successfully loaded.', messageOnce: true, }, onFail: { message: 'Dependency has not loaded yet.', messageOnce: false, }, }, { name: 'Modified game script', passes: () => DependencyManager.dependencies.modifiedGameScript.isLoaded(), beforeCheck: { message: `We have to make sure the modified game script has loaded. A modified script with the identifier '${() => Core.settings.modifiedGameScript.identifier}' might not exist in our repo yet.`, messageOnce: true, }, onPass: { message: 'Modified game script has successfully loaded.', messageOnce: true, }, onFail: { message: 'Unable to detect the modified game script. A slow connection can also cause this issue. Triggering failsafe...', messageOnce: true, run: () => { const prefix = '[FAILSAFE]' Core.log(prefix, 'Sorry, the modified game script didn\'t load. Reverting back to the original game script.') Toastmaker.makeToast( `<b><a style="color: #9bb5ff" href="https://github.com/Opinion/slowroads-handling-editor">Opinion's Handling Editor</a></b></br> Unable to load modified game script. Game script identifier: '${Core.settings.modifiedGameScript.identifier}'. Please create an issue if one hasn't been made yet. Remember to include any logs from your browsers console. Thanks. ~Opinion<br><br> Likely reason for this error: slowroads.io has updated their game scripts and we have yet to include the new version in our repository.<br><br> As a failsafe, we loaded the original game script so you can enjoy the unmodded game.`, 'error', 240000, ) DocumentInteraction.appendScript(Core.settings.originalGameScript + '?ignore') Core.log(prefix, 'Throwing an exception to stop the Handling Editor. Please check the repo to see if a matching script actually exists.') throw Error('Modified game script didn\'t load. Failsafe triggered and loaded the original game script.') }, runOnce: true, }, }, { name: 'Game version', passes: () => WindowInteraction.getExposedVersion() === Core.settings.supportedVersion, beforeCheck: { message: `Required game version: '${Core.settings.supportedVersion}'.`, messageOnce: true, }, onPass: { message: 'The game version is supported.', messageOnce: true, }, onFail: { message: `Game version '${WindowInteraction.getExposedVersion()}' is not supported. Please check if the Handling Editor has a new release available.`, messageOnce: true, run: () => Toastmaker.makeToast('<b><a style="color: #9bb5ff" href="https://github.com/Opinion/slowroads-handling-editor">Opinion\'s Handling Editor</a></b></br>Game version is not supported. Please check if the Handling Editor has a new release available.', 'error', 100000), runOnce: true, throwException: true, }, }, { name: 'Game start', passes: () => WindowInteraction.getExposedI()?.current?.firstFrame === true, beforeCheck: { message: 'Waiting for the game to start (press \'begin\')...', messageOnce: true, }, onPass: { message: 'The game has started.', messageOnce: true, }, }, { name: 'Vehicle spawn', passes: () => typeof WindowInteraction.getExposedI()?.current?.vehicleController !== 'undefined', beforeCheck: { message: 'Waiting for the vehicle to spawn...', messageOnce: true, }, onPass: { message: 'Found \'vehicleController\'.', messageOnce: true, }, onFail: { message: 'Couldn\'t find \'vehicleController\'. This usually takes a few seconds.', messageOnce: false, }, }, ], internal: { /** * Internal logger for conditions * * @param {object} condition * @param {...any} args */ log(condition, ...args) { Core.log(`[Condition: ${condition.name}]`, ...args) }, /** * Handle condition sections; 'beforeCheck', 'onPass' and 'onFail' * * Default values described in parent-JSDoc are marked with inline comments. * * Steps in order: * 1. Log message if allowed * 2. Run function if allowed * 3. Throw exception if allowed * * @param {object} condition Condition object * @param {string} section Section name */ handleSection(condition, section) { // Validate section type if (!['beforeCheck', 'onPass', 'onFail'].includes(section)) { throw Error(`Section '${section}' is not valid.`) } // Get state const stateMessageKey = `${section}Message` const stateRunKey = `${section}Run` const messageHasAlreadyBeenLogged = condition.state[stateMessageKey] === true const runHasAlreadyBeenExecuted = condition.state[stateRunKey] === true // Get section settings const sectionSettings = condition[section] const messageCanOnlyBeLoggedOnce = sectionSettings.messageOnce !== false // Note: Defaults to 'true' as described in JSDoc const runCanOnlyBeExecutedOnce = sectionSettings.messageOnce !== false // Note: Defaults to 'true' as described in JSDoc // Check if we are allowed to log a message const messageIsAString = typeof sectionSettings.message === 'string' const messageCanBeLogged = messageHasAlreadyBeenLogged ? !messageCanOnlyBeLoggedOnce : true if (messageIsAString && messageCanBeLogged) { // Log the message this.log(condition, sectionSettings.message) // Save to state condition.state[stateMessageKey] = true } // Check if we are allowed to run a function const runIsAFunction = typeof sectionSettings.run === 'function' const runCanBeExecuted = runHasAlreadyBeenExecuted ? !runCanOnlyBeExecutedOnce : true if (runIsAFunction && runCanBeExecuted) { // Run the function sectionSettings.run() // Save to state condition.state[stateRunKey] = true } // Check if we are allowed to throw an exception if (sectionSettings.throwException === true) { // Note: Defaults to 'false' as described in JSDoc this.log( condition, 'Halting script exectution because \'throwException\' was true.', { conditionName: condition.name, condition, sectionName: section, section: sectionSettings }, ) throw Error(`This exception was thrown by a condition in order to halt script execution. Condition name: '${condition.name}', Section: '${section}'.`) } }, /** * Check a condition * * Triggers 'beforeCheck', 'onPass' and 'onFail' when needed. * * @param {*} condition */ checkCondition(condition) { // Make sure 'beforeCheck', 'onPass' and 'onFail' is an object if (typeof condition.beforeCheck !== 'object') { condition.beforeCheck = {} } if (typeof condition.onPass !== 'object') { condition.onPass = {} } if (typeof condition.onFail !== 'object') { condition.onFail = {} } // Make sure state is an object if (typeof condition.state !== 'object') { condition.state = {} } // Get state const conditionHasAlreadyPassed = condition.state.passed === true // Section :: 'beforeCheck' this.handleSection(condition, 'beforeCheck') // Checking condition if (conditionHasAlreadyPassed || condition.passes()) { // Save to state condition.state.passed = true // Important -- Condition will always pass on upcoming checks // Section :: 'onPass' this.handleSection(condition, 'onPass') // Output: The condition passed return true } else { // Section :: 'onFail' this.handleSection(condition, 'onFail') // Output: The condition failed return false } }, }, /** * Check all conditions * * @returns {boolean} */ check() { const conditions = this.conditions for (let i = 0; i < conditions.length; i++) { const condition = conditions[i] if (!this.internal.checkCondition(condition)) { // Output: A condition has failed return false } } // Output: All conditions have passed return true }, } const HandlingEditor = { /* Include 'Core' */ log: Core.log, settings: Core.settings, /* Include 'DocumentInteraction' */ appendScript: DocumentInteraction.appendScript, appendStyle: DocumentInteraction.appendStyle, appendRawScript: DocumentInteraction.appendRawScript, getGameCanvasElement: DocumentInteraction.getGameCanvasElement, /* Include 'Toastmaker' */ toastmaker: Toastmaker, /* Include 'ConditionChecker' */ conditionChecker: ConditionChecker, /** * Hanling keys available in the handling editor * (if you want to add more, remember to add matching HTML inputs too) */ handlingKeys: [ 'accel', /* */'topSpeed', 'brake', /* */'reverse', 'aeroFactor', /* */'dampening', 'drag', /* */'jerk', 'rockFactor', /* */'rollResistance', 'slipBase', /* */'slipMod', 'maxSteer', /* */'steerSpeed', 'steerAccel', /* */'steerInterval', 'mass', ], /** * Initialize scripts and dependencies */ initialize() { this.log('Initializing...') // Load dependencies DependencyManager.load() // Run ConditionChecker until we can start the Handling Editor this.log('Before we start the Handling Editor, we need to wait for some conditions...') const self = this this.settings.pleaseWait = setInterval(() => { // Checking conditions try { if (!self.conditionChecker.check()) { return } } catch (exception) { self.log('Caught an exception in ConditionChecker. Stopping loop and halting the script.', exception) clearInterval(self.settings.pleaseWait) // Important -- stops timer return // Important -- halts script } // Stopping timer and starting handling editor self.log('Passed all conditions. Starting handling editor!') clearInterval(self.settings.pleaseWait) self.startHandlingEditor() }, 100) }, /** * Starts handling editor (main) */ startHandlingEditor() { this.toastmaker.makeToast('You can open the handling editor by pressing the cog in the <b>top left</b> corner.', 'default', 25000) this.toastmaker.makeToast('🔧 Now playing with Opinion\'s handling editor. Have fun out there :)', 'info', 12000) // Window toggled and position const windowX = '4.5rem' const windowY = '2rem' let windowActiveClass = '' if (localStorage.getItem('x-handling-editor-active')) { windowActiveClass = 'active' } // Create Handling Editor UI const handlingEditorOuterElement = document.createElement('div') handlingEditorOuterElement.id = 'handling-editor-outer' handlingEditorOuterElement.innerHTML = ` <div id="handling-editor-toggle" class="active"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> </svg> </div> <div id="handling-editor" class="noselect ${windowActiveClass}"> <div id="handling-editor-header" class="title"> <div></div> Opinion's Handling Editor <div></div> </div> <div class="control-grid"> <div class="left"> <!-- Left side --> <div> <label for="handling-accel">accel</label> <input type="text" id="handling-accel"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-brake">brake</label> <input type="text" id="handling-brake"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-aeroFactor">aeroFactor</label> <input type="text" id="handling-aeroFactor"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-drag">drag</label> <input type="text" id="handling-drag"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-rockFactor">rockFactor</label> <input type="text" id="handling-rockFactor"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-slipBase">slipBase</label> <input type="text" id="handling-slipBase"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-maxSteer">maxSteer</label> <input type="text" id="handling-maxSteer"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-steerAccel">steerAccel</label> <input type="text" id="handling-steerAccel"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-mass">mass</label> <input type="text" id="handling-mass"> <div class="saved">✓ Saved</div> </div> </div> <div class="right"> <!-- Right side --> <div> <label for="handling-topSpeed">topSpeed</label> <input type="text" id="handling-topSpeed"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-reverse">reverse</label> <input type="text" id="handling-reverse"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-dampening">dampening</label> <input type="text" id="handling-dampening"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-jerk">jerk</label> <input type="text" id="handling-jerk"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-rollResistance">rollResistance</label> <input type="text" id="handling-rollResistance"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-slipMod">slipMod</label> <input type="text" id="handling-slipMod"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-steerSpeed">steerSpeed</label> <input type="text" id="handling-steerSpeed"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-steerInterval">steerInterval</label> <input type="text" id="handling-steerInterval"> <div class="saved">✓ Saved</div> </div> <div> <label for="handling-reset">Messed up?</label> <button id="handling-reset" style="width: 100%;">reset handling</button> </div> </div> </div> </div> <style> #handling-editor-outer {} #handling-editor-toggle { display: none; position: absolute; top: 2rem; left: 2rem; width: 24px; height: 24px; color: hsla(0,0%,100%,.7333333333333333); z-index: 10; padding: 4px; border-radius: 50%; cursor: pointer; background-color: #22222242; } #handling-editor-toggle:hover { background-color: #222; color: #fff; } #handling-editor-toggle.active { display: block; } #handling-editor { position: absolute; left: ${windowX}; top: ${windowY}; display: none; width: 350px; background: rgba(30, 30, 30, 0.64); z-index: 10; color: #fff; } #handling-editor.active { display: block; } #handling-editor .title { display: flex; justify-content: space-between; place-items: center; gap: 16px; padding: 12px 16px 12px 16px; text-align: center; text-shadow: 2px 2px 0px #000; cursor: grab; } #handling-editor .title.grabbing { cursor: grabbing; } #handling-editor .title div{ flex-grow: 1; border-bottom: 1px solid #999; } #handling-editor .control-grid{ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } #handling-editor .control-grid * { box-sizing: border-box; } #handling-editor .control-grid > .left { margin-left: 16px; } #handling-editor .control-grid > .right { margin-right: 16px; } #handling-editor .control-grid > div > div { position: relative; margin-top: 10px; } #handling-editor .control-grid > div > div > input { display: inline-block; width: 100%; background: #222222aa; border: none; color: #BBBECE; padding: 2px 8px; border-bottom: 2px solid transparent; transition: all ease-in-out 200ms; } #handling-editor .control-grid > div > div > input:focus { border-bottom: 2px solid #424f82; box-shadow: 2px 2px 5px 0px #2424243d; } #handling-editor .control-grid > div > div > .saved { position: absolute; top: 22px; right: 4px; font-size: 10px; color: #CCC; opacity: 0; transition: all ease-in-out 500ms; pointer-events: none; } #handling-editor .control-grid > div > div > .saved.active { opacity: 1; color: #20FF00; } .noselect { -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Safari */ -khtml-user-select: none; /* Konqueror HTML */ -moz-user-select: none; /* Old versions of Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ } </style> ` document.body.append(handlingEditorOuterElement) const handlingEditorToggleElement = document.getElementById('handling-editor-toggle') const handlingEditorElement = document.getElementById('handling-editor') // Making handling editor draggable this.dragElement(handlingEditorElement) // Event :: Toggle handling editor handlingEditorToggleElement.addEventListener('click', function() { handlingEditorElement.classList.toggle('active') if (handlingEditorElement.classList.contains('active')) { localStorage.setItem('x-handling-editor-active', true) } else { localStorage.removeItem('x-handling-editor-active') } }) // Loading handling (and create event listeners) this.loadHandling(true) // Creating event listener for reset button const self = this const resetButtonElement = document.getElementById('handling-reset') resetButtonElement.addEventListener('click', () => { self.resetHandling() }) }, /** * Attempt to put focus on the game's canvas */ putFocusOnGameCanvas() { function focus() { DocumentInteraction.getGameCanvasElement()?.focus() } // Get active element const activeElement = document.activeElement if (activeElement?.tagName === 'INPUT') { // -- Active element is an input // Only focus if the active element is the same element that was recently updated. // This allows you to click on another input (triggers 'change' event) without giving focus to the game. // We assume; if you click another input, you want to keep editing and not play right away. if (activeElement === this.recentlyUpdatedInputElement) { focus() } } else { // -- Active element is anything else // Always focus focus() } // Resetting this.recentlyUpdatedInputElement = null }, updateHandling(handlingKey, value) { const handlingMetrics = WindowInteraction.getExposedI().current.vehicleController.vehicleDef.metrics handlingMetrics[handlingKey] = parseFloat(value) this.putFocusOnGameCanvas() }, resetHandling() { if (typeof this.settings.defaultHandling === 'undefined') { this.toastmaker.makeToast('Can\'t reset handling. Default values have not been intialized yet.') return } for (let i = 0; i < this.handlingKeys.length; i++) { const handlingKey = this.handlingKeys[i] // Resetting handling for the current key const handlingMetrics = WindowInteraction.getExposedI().current.vehicleController.vehicleDef.metrics handlingMetrics[handlingKey] = this.settings.defaultHandling[handlingKey] } // Loading new values to UI this.loadHandling(false) this.putFocusOnGameCanvas() }, loadHandling(firstRun = false) { if (firstRun) { this.settings.defaultHandling = {} } for (let i = 0; i < this.handlingKeys.length; i++) { const handlingKey = this.handlingKeys[i] // Getting corresponding input and setting initial value const inputElement = document.getElementById(`handling-${handlingKey}`) const handlingMetrics = WindowInteraction.getExposedI().current.vehicleController.vehicleDef.metrics const initialValue = handlingMetrics[handlingKey] inputElement.value = initialValue this.log('Set initial value of', handlingKey, 'to', initialValue) if (firstRun) { // Storing in default handling this.settings.defaultHandling[handlingKey] = initialValue // Creating event listener const self = this inputElement.addEventListener('change', () => { self.recentlyUpdatedInputElement = inputElement self.updateHandling(handlingKey, inputElement.value) // Display saved text in UI const savedElement = inputElement.parentElement.children[2] savedElement?.classList?.add('active') setTimeout(() => { savedElement?.classList?.remove('active') }, 2500) }) } } }, dragElement(element) { let pos1 = 0; let pos2 = 0; let pos3 = 0; let pos4 = 0 const headerElement = document.getElementById(element.id + '-header') if (headerElement) { // if present, the header is where you move the DIV from: headerElement.onmousedown = dragMouseDown } else { // otherwise, move the DIV from anywhere inside the DIV: element.onmousedown = dragMouseDown } function dragMouseDown(e) { e = e || WindowInteraction.getWindow().event e.preventDefault() // get the mouse cursor position at startup: pos3 = e.clientX pos4 = e.clientY document.onmouseup = closeDragElement // call a function whenever the cursor moves: document.onmousemove = elementDrag if (headerElement) { headerElement.classList.add('grabbing') } } function elementDrag(e) { e = e || WindowInteraction.getWindow().event e.preventDefault() // calculate the new cursor position: pos1 = pos3 - e.clientX pos2 = pos4 - e.clientY pos3 = e.clientX pos4 = e.clientY // set the element's new position: element.style.top = (element.offsetTop - pos2) + 'px' element.style.left = (element.offsetLeft - pos1) + 'px' } function closeDragElement() { // stop moving when mouse button is released: document.onmouseup = null document.onmousemove = null if (headerElement) { headerElement.classList.remove('grabbing') } } }, }; (function() { 'use strict' addEventListener('beforescriptexecute', (event) => { const url = event.target.src // If the URL ends with '?ignore' we know we know the failsafe has triggered. // We want to return as to let the original game script run like normal. if (url.endsWith('?ignore')) { return } // Check if URL matches pattern of the original game script const matches = Core.settings.originalGameScriptPattern.exec(url) // Skipping non-matching scripts if (matches === null || !Array.isArray(matches) || matches.length !== 2) { return } // Getting and storing original game script // This will be used in the failsafe built in to the ConditionChecker. If the modified game script doesn't // load it will revert back to the original game script instead. You won't get to use the Handling Editor // but at least you can play the game. const originalGameScript = matches[0] Core.settings.originalGameScript = originalGameScript // Getting and storing identifier // This identifier will be used when we load a modified game script in the repo's '/dist' directory. // By doing this we can essentially update the repo without mod users needing to update their userscript. const identifier = matches[1] Core.settings.modifiedGameScript.identifier = identifier Core.log('Detected original game script. The identifer:', identifier, 'will be used to load a modified game script from our repo.', { event: event, element: event.target, url: url }) // Preventing execution of the original game script event.preventDefault() Core.log('Prevented execution of the original game script.') HandlingEditor.initialize() }) })()