// ==UserScript== // @name betteR20-5etools // @namespace https://5e.tools/ // @license MIT (https://opensource.org/licenses/MIT) // @version 1.16.13 // @updateURL https://get.5e.tools/script/betteR20-5etools.user.js // @downloadURL https://get.5e.tools/script/betteR20-5etools.user.js // @description Enhance your Roll20 experience // @author 5egmegaanon/astranauta/MrLabRat/TheGiddyLimit/DBAWiseMan/BDeveau/Remuz/Callador Julaan/Erogroth/Stormy/FlayedOne // @match https://app.roll20.net/editor // @match https://app.roll20.net/editor#* // @match https://app.roll20.net/editor?* // @match https://app.roll20.net/editor/ // @match https://app.roll20.net/editor/#* // @match https://app.roll20.net/editor/?* // @grant unsafeWindow // @run-at document-start // ==/UserScript== ART_HANDOUT = "betteR20-art"; CONFIG_HANDOUT = "betteR20-config"; BASE_SITE_URL = "https://5e.tools/"; // TODO automate to use mirror if main site is unavailable SITE_JS_URL = BASE_SITE_URL + "js/"; DATA_URL = BASE_SITE_URL + "data/"; SCRIPT_EXTENSIONS = []; CONFIG_OPTIONS = { interface: { _name: "Interface", showCustomArtPreview: { name: "Show Custom Art Previews", default: true, _type: "boolean" } } }; addConfigOptions = function (category, options) { if (!CONFIG_OPTIONS[category]) CONFIG_OPTIONS[category] = options; else CONFIG_OPTIONS[category] = Object.assign(CONFIG_OPTIONS[category], options); }; OBJECT_DEFINE_PROPERTY = Object.defineProperty; ACCOUNT_ORIGINAL_PERMS = { largefeats: false, xlfeats: false }; Object.defineProperty = function (obj, prop, vals) { try { if (prop === "largefeats" || prop === "xlfeats") { ACCOUNT_ORIGINAL_PERMS[prop] = vals.value; vals.value = true; } OBJECT_DEFINE_PROPERTY(obj, prop, vals); } catch (e) { console.log("failed to define property:"); console.log(e); console.log(obj, prop, vals); } }; FINAL_CANVAS_MOUSEDOWN_LIST = []; FINAL_CANVAS_MOUSEMOVE_LIST = []; FINAL_CANVAS_MOUSEDOWN = null; FINAL_CANVAS_MOUSEMOVE = null; EventTarget.prototype.addEventListenerBase = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options, ...others) { if (typeof d20 !== "undefined") { if (type === "mousedown" && this === d20.engine.final_canvas) FINAL_CANVAS_MOUSEDOWN = listener; if (type === "mousemove" && this === d20.engine.final_canvas) FINAL_CANVAS_MOUSEMOVE = listener; } else { if (type === "mousedown") FINAL_CANVAS_MOUSEDOWN_LIST.push({listener, on: this}); if (type === "mousemove") FINAL_CANVAS_MOUSEMOVE_LIST.push({listener, on: this}); } this.addEventListenerBase(type, listener, options, ...others); }; function baseUtil () { d20plus.ut = {}; d20plus.ut.log = (...args) => { console.log("%cD20Plus > ", "color: #3076b9; font-size: large", ...args); }; d20plus.ut.error = (...args) => { console.error("%cD20Plus > ", "color: #b93032; font-size: large", ...args); }; d20plus.ut.chatLog = (arg) => { d20.textchat.incoming( false, { who: "betteR20", type: "general", content: (arg || "").toString(), playerid: window.currentPlayer.id, id: d20plus.ut.generateRowId(), target: window.currentPlayer.id, avatar: "https://i.imgur.com/bBhudno.png" } ); }; d20plus.ut.ascSort = (a, b) => { if (b === a) return 0; return b < a ? 1 : -1; }; d20plus.ut.disable3dDice = () => { d20plus.ut.log("Disabling 3D dice"); const $cb3dDice = $(`#enable3ddice`); $cb3dDice.prop("checked", false).attr("disabled", true); $cb3dDice.closest("p").after(`

3D dice are incompatible with betteR20. We apologise for any inconvenience caused.

`); $(`#autoroll`).prop("checked", false).attr("disabled", true);; d20.tddice.canRoll3D = () => false; }; d20plus.ut.checkVersion = (scriptType) => { d20plus.ut.log("Checking current version"); function cmpVersions (a, b) { const regExStrip0 = /(\.0+)+$/; const segmentsA = a.replace(regExStrip0, '').split('.'); const segmentsB = b.replace(regExStrip0, '').split('.'); const l = Math.min(segmentsA.length, segmentsB.length); for (let i = 0; i < l; i++) { const diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10); if (diff) { return diff; } } return segmentsA.length - segmentsB.length; } let scriptUrl; switch (scriptType) { case "core": scriptType = `https://get.5e.tools/script/betteR20-core.user.js${d20plus.ut.getAntiCacheSuffix()}`; break; case "5etools": scriptType = `https://get.5e.tools/script/betteR20-5etools.user.js${d20plus.ut.getAntiCacheSuffix()}`; break; default: scriptUrl = "https://get.5e.tools/"; break; } $.ajax({ url: `https://get.5e.tools`, success: (data) => { const m = //.exec(data); if (m) { const curr = d20plus.version; const avail = m[1]; const cmp = cmpVersions(curr, avail); if (cmp < 0) { setTimeout(() => { d20plus.ut.sendHackerChat(`A newer version of the script is available. Get ${avail} here. For help and support, see our Discord.`); }, 1000); } } }, error: () => { d20plus.ut.log("Failed to check version"); } }) }; d20plus.ut.chatTag = (message) => { const isStreamer = !!d20plus.cfg.get("interface", "streamerChatTag"); d20plus.ut.sendHackerChat(` ${isStreamer ? "Script" : message} initialised. ${window.enhancementSuiteEnabled ? `

Roll20 Enhancement Suite detected.` : ""} ${isStreamer ? "" : `

Need help? Join our Discord.

Please DO NOT post about this script or any related content in official channels, including the Roll20 forums.

Before reporting a bug on the Roll20 forums, please disable the script and check if the problem persists. `}
`); }; d20plus.ut.showLoadingMessage = (message) => { const isStreamer = !!d20plus.cfg.get("interface", "streamerChatTag"); d20plus.ut.sendHackerChat(` ${isStreamer ? "Script" : message} initialising, please wait...

`); }; d20plus.ut.sendHackerChat = (message) => { d20.textchat.incoming(false, ({ who: "system", type: "system", content: ` ${message} ` })); }; d20plus.ut.addCSS = (sheet, selectors, rules) => { if (!(selectors instanceof Array)) selectors = [selectors]; selectors.forEach(selector => { const index = sheet.cssRules.length; try { if ("insertRule" in sheet) { sheet.insertRule(selector + "{" + rules + "}", index); } else if ("addRule" in sheet) { sheet.addRule(selector, rules, index); } } catch (e) { if ((!selector && selector.startsWith("-webkit-"))) { console.error(e); console.error(`Selector was "${selector}"; rules were "${rules}"`); } } }); }; d20plus.ut.addAllCss = () => { d20plus.ut.log("Adding CSS"); const targetSheet = [...window.document.styleSheets] .filter(it => it.href && (!it.href.startsWith("moz-extension") && !it.href.startsWith("chrome-extension"))) .find(it => it.href.includes("app.css")); _.each(d20plus.css.baseCssRules, function (r) { d20plus.ut.addCSS(targetSheet, r.s, r.r); }); if (!window.is_gm) { _.each(d20plus.css.baseCssRulesPlayer, function (r) { d20plus.ut.addCSS(targetSheet, r.s, r.r); }); } _.each(d20plus.css.cssRules, function (r) { d20plus.ut.addCSS(targetSheet, r.s, r.r); }); }; d20plus.ut.getAntiCacheSuffix = () => { return "?" + (new Date()).getTime(); }; d20plus.ut.generateRowId = () => { return window.generateUUID().replace(/_/g, "Z"); }; d20plus.ut.randomRoll = (roll, success, error) => { d20.textchat.diceengine.process(roll, success, error); }; d20plus.ut.getJournalFolderObj = () => { d20.journal.refreshJournalList(); let journalFolder = d20.Campaign.get("journalfolder"); if (journalFolder === "") { d20.journal.addFolderToFolderStructure("Characters"); d20.journal.refreshJournalList(); journalFolder = d20.Campaign.get("journalfolder"); } return JSON.parse(journalFolder); }; d20plus.ut._lastInput = null; d20plus.ut.getNumberRange = (promptText, min, max) => { function alertInvalid () { alert("Please enter a valid range."); } function isOutOfRange (num) { return num < min || num > max; } function addToRangeVal (range, num) { range.add(num); } function addToRangeLoHi (range, lo, hi) { for (let i = lo; i <= hi; ++i) { range.add(i); } } function alertOutOfRange () { alert(`Please enter numbers in the range ${min}-${max} (inclusive).`); } while (true) { const res = prompt(promptText, d20plus.ut._lastInput || "E.g. 1-5, 8, 11-13"); if (res && res.trim()) { d20plus.ut._lastInput = res; const clean = res.replace(/\s*/g, ""); if (/^((\d+-\d+|\d+),)*(\d+-\d+|\d+)$/.exec(clean)) { const parts = clean.split(","); const out = new Set(); let failed = false; for (const part of parts) { if (part.includes("-")) { const spl = part.split("-"); const numLo = Number(spl[0]); const numHi = Number(spl[1]); if (isNaN(numLo) || isNaN(numHi) || numLo === 0 || numHi === 0 || numLo > numHi) { alertInvalid(); failed = true; break; } if (isOutOfRange(numLo) || isOutOfRange(numHi)) { alertOutOfRange(); failed = true; break; } if (numLo === numHi) { addToRangeVal(out, numLo); } else { addToRangeLoHi(out, numLo, numHi); } } else { const num = Number(part); if (isNaN(num) || num === 0) { alertInvalid(); failed = true; break; } else { if (isOutOfRange(num)) { alertOutOfRange(); failed = true; break; } addToRangeVal(out, num); } } } if (!failed) { d20plus.ut._lastInput = null; return out; } } else { alertInvalid(); } } else { d20plus.ut._lastInput = null; return null; } } }; d20plus.ut.getPathById = (pathId) => { return d20plus.ut._getCanvasElementById(pathId, "thepaths"); }; d20plus.ut.getTokenById = (tokenId) => { return d20plus.ut._getCanvasElementById(tokenId, "thegraphics"); }; d20plus.ut._getCanvasElementById = (id, prop) => { const foundArr = d20.Campaign.pages.models.map(model => model[prop] && model[prop].models ? model[prop].models.find(it => it.id === id) : null).filter(it => it); return foundArr.length ? foundArr[0] : null; }; d20plus.ut.getMacroByName = (macroName) => { const macros = d20.Campaign.players.map(p => p.macros.find(m => m.get("name") === macroName && (p.id === window.currentPlayer.id || m.visibleToCurrentPlayer()))) .filter(Boolean); if (macros.length) { return macros[0]; } return null; }; d20plus.ut._BYTE_UNITS = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; d20plus.ut.getReadableFileSizeString = (fileSizeInBytes) => { let i = -1; do { fileSizeInBytes = fileSizeInBytes / 1024; i++; } while (fileSizeInBytes > 1024); return Math.max(fileSizeInBytes, 0.1).toFixed(1) + d20plus.ut._BYTE_UNITS[i]; }; d20plus.ut.sanitizeFilename = function (str) { return str.trim().replace(/[^\w\-]/g, "_"); }; // based on: /*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/src/FileSaver.js */ d20plus.ut.saveAs = function() { const view = window; var doc = view.document // only get URL when necessary in case Blob.js hasn't overridden it yet , get_URL = function() { return view.URL || view.webkitURL || view; } , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") , can_use_save_link = "download" in save_link , click = function(node) { var event = new MouseEvent("click"); node.dispatchEvent(event); } , is_safari = /constructor/i.test(view.HTMLElement) || view.safari , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent) , setImmediate = view.setImmediate || view.setTimeout , throw_outside = function(ex) { setImmediate(function() { throw ex; }, 0); } , force_saveable_type = "application/octet-stream" // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to , arbitrary_revoke_timeout = 1000 * 40 // in ms , revoke = function(file) { var revoker = function() { if (typeof file === "string") { // file is an object URL get_URL().revokeObjectURL(file); } else { // file is a File file.remove(); } }; setTimeout(revoker, arbitrary_revoke_timeout); } , dispatch = function(filesaver, event_types, event) { event_types = [].concat(event_types); var i = event_types.length; while (i--) { var listener = filesaver["on" + event_types[i]]; if (typeof listener === "function") { try { listener.call(filesaver, event || filesaver); } catch (ex) { throw_outside(ex); } } } } , auto_bom = function(blob) { // prepend BOM for UTF-8 XML and text/* types (including HTML) // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type}); } return blob; } , FileSaver = function(blob, name, no_auto_bom) { if (!no_auto_bom) { blob = auto_bom(blob); } // First try a.download, then web filesystem, then object URLs var filesaver = this , type = blob.type , force = type === force_saveable_type , object_url , dispatch_all = function() { dispatch(filesaver, "writestart progress write writeend".split(" ")); } // on any filesys errors revert to saving with object URLs , fs_error = function() { if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { // Safari doesn't allow downloading of blob urls var reader = new FileReader(); reader.onloadend = function() { var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); var popup = view.open(url, '_blank'); if(!popup) view.location.href = url; url=undefined; // release reference before dispatching filesaver.readyState = filesaver.DONE; dispatch_all(); }; reader.readAsDataURL(blob); filesaver.readyState = filesaver.INIT; return; } // don't create more object URLs than needed if (!object_url) { object_url = get_URL().createObjectURL(blob); } if (force) { view.location.href = object_url; } else { var opened = view.open(object_url, "_blank"); if (!opened) { // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html view.location.href = object_url; } } filesaver.readyState = filesaver.DONE; dispatch_all(); revoke(object_url); }; filesaver.readyState = filesaver.INIT; if (can_use_save_link) { object_url = get_URL().createObjectURL(blob); setImmediate(function() { save_link.href = object_url; save_link.download = name; click(save_link); dispatch_all(); revoke(object_url); filesaver.readyState = filesaver.DONE; }, 0); return; } fs_error(); } , FS_proto = FileSaver.prototype , saveAs = function(blob, name, no_auto_bom) { return new FileSaver(blob, name || blob.name || "download", no_auto_bom); }; // IE 10+ (native saveAs) if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { return function(blob, name, no_auto_bom) { name = name || blob.name || "download"; if (!no_auto_bom) { blob = auto_bom(blob); } return navigator.msSaveOrOpenBlob(blob, name); }; } FS_proto.abort = function(){}; FS_proto.readyState = FS_proto.INIT = 0; FS_proto.WRITING = 1; FS_proto.DONE = 2; FS_proto.error = FS_proto.onwritestart = FS_proto.onprogress = FS_proto.onwrite = FS_proto.onabort = FS_proto.onerror = FS_proto.onwriteend = null; return saveAs; }(); d20plus.ut.promiseDelay = function (delay) { return new Promise(resolve => { setTimeout(() => resolve(), delay); }) }; d20plus.ut.LAYERS = ["map", "background", "objects", "foreground", "gmlayer", "walls", "weather"]; d20plus.ut.layerToName = (l) => { switch (l) { case "map": return "Map"; case "background": return "Background"; case "objects": return "Objects & Tokens"; case "foreground": return "Foreground"; case "gmlayer": return "GM Info Overlay"; case "walls": return "Dynamic Lighting"; case "weather": return "Weather Exclusions"; } }; d20plus.ut.get$SelValue = ($sel) => { return $sel[0].options[$sel[0].selectedIndex].value; }; d20plus.ut.isUseSharedJs = () => { return BASE_SITE_URL.includes("://5e.tools") || BASE_SITE_URL.includes("://5etools.com"); }; d20plus.ut.fixSidebarLayout = () => { $(`#textchat-input`).insertAfter(`#textchat`); const cached = d20.textchat.showPopout; d20.textchat.showPopout = function () { cached(); const cached2 = d20.textchat.childWindow.onbeforeunload; d20.textchat.childWindow.onbeforeunload = function () { cached2(); $(`#textchat-input`).insertAfter(`#textchat`); } } }; /** * Assumes any other lists have been searched using the same term */ d20plus.ut.getSearchTermAndReset = (list, ...otherLists) => { let lastSearch = null; if (list.searched) { lastSearch = $(`#search`).val(); list.search(); otherLists.forEach(l => l.search()); } list.filter(); otherLists.forEach(l => l.filter()); return lastSearch; }; } SCRIPT_EXTENSIONS.push(baseUtil); /* map afow grid background objects foreground gmlayer walls weather */ function baseJsLoad () { d20plus.js = {}; d20plus.js.scripts = [ {name: "listjs", url: "https://raw.githubusercontent.com/javve/list.js/v1.5.0/dist/list.min.js"}, {name: "localforage", url: "https://raw.githubusercontent.com/localForage/localForage/1.7.3/dist/localforage.min.js"}, {name: "JSZip", url: `https://raw.githubusercontent.com/Stuk/jszip/master/dist/jszip.min.js`}, ]; if (d20plus.ut.isUseSharedJs()) d20plus.js.scripts.push({name: "5etoolsShared", url: `${SITE_JS_URL}shared.js`}); else d20plus.js.scripts.push({name: "5etoolsUtils", url: `${SITE_JS_URL}utils.js`}); d20plus.js.apiScripts = [ {name: "VecMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/Vector%20Math/1.0/VecMath.js"}, {name: "MatrixMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/MatrixMath/1.0/matrixMath.js"}, {name: "PathMath", url: "https://raw.githubusercontent.com/Roll20/roll20-api-scripts/master/PathMath/1.5/PathMath.js"} ]; d20plus.js.pAddScripts = async () => { d20plus.ut.log("Add JS"); await Promise.all(d20plus.js.scripts.map(async it => { const js = await d20plus.js.pLoadWithRetries(it.name, it.url); d20plus.js._addScript(it.name, js) })); // Monkey patch JSON loading const cached = DataUtil.loadJSON; DataUtil.loadJSON = (...args) => { if (args.length > 0 && typeof args[0] === "string" && args[0].startsWith("data/")) { args[0] = BASE_SITE_URL + args[0]; } return cached.bind(DataUtil)(...args); }; }; d20plus.js.pAddApiScripts = async () => { d20plus.ut.log("Add Builtin API Scripts"); await Promise.all(d20plus.js.apiScripts.map(async it => { const js = await d20plus.js.pLoadWithRetries(it.name, it.url); d20plus.js._addScript(it.name, js); })); }; d20plus.js._addScript = (name, js) => { // sanity check if (js instanceof Promise) throw new Error(`Promise was passed instead of text! This is a bug.`); try { window.eval(js); d20plus.ut.log(`JS [${name}] Loaded`); } catch (e) { d20plus.ut.log(`Error loading [${name}]`); d20plus.ut.log(e); throw e; } }; d20plus.js.pLoadWithRetries = async (name, url, isJson) => { let retries = 3; function pFetchData () { return new Promise((resolve, reject) => { $.ajax({ type: "GET", url: `${url}${d20plus.ut.getAntiCacheSuffix()}${retries}`, success: function (data) { if (isJson && typeof data === "string") resolve(JSON.parse(data)); else resolve(data); }, error: function (resp, qq, pp) { if (resp && resp.status >= 400 && retries-- > 0) { console.error(resp, qq, pp); d20plus.ut.log(`Error loading ${name}; retrying`); setTimeout(() => { reject(new Error(`Loading "${name}" failed (status ${resp.status}): ${resp} ${qq} ${pp}`)); }, 500); } else { console.error(resp, qq, pp); setTimeout(() => { reject(new Error(`Loading "${name}" failed (status ${resp.status}): ${resp} ${qq} ${pp}`)); }, 500); } } }); }) } let data; do { try { data = await pFetchData(); } catch (e) {} // error handling is done as part of data fetching } while (!data && --retries > 0); if (data) return data; else throw new Error(`Failed to load ${name} from URL ${url} (isJson: ${!!isJson})`); }; } SCRIPT_EXTENSIONS.push(baseJsLoad); function baseQpi () { const qpi = { _version: "0.01-pre-pre-alpha", _: { log: { _ (...args) { qpi._log(...args) }, works: 1 }, // Campaign: { // FIXME this overwrites the window's campaign, which breaks stuff // _ () { // return Campaign; // }, // works: 0 // }, on: { _preInit () { qpi._on_chatHandlers = []; const seenMessages = new Set(); d20.textchat.chatref = d20.textchat.shoutref.parent().child("chat"); const handleChat = (e) => { if (!d20.textchat.chatstartingup) { e.id = e.key(); if (!seenMessages.has(e.id)) { seenMessages.add(e.id); var t = e.val(); if (t) { if (window.DEBUG) console.log("CHAT: ", t); qpi._on_chatHandlers.forEach(fn => fn(t)); } } } }; d20.textchat.chatref.on("child_added", handleChat); d20.textchat.chatref.on("child_changed", handleChat); }, _ (evtType, fn, ...others) { switch (evtType) { case "chat:message": qpi._on_chatHandlers.push(fn); break; default: console.error("Unhandled message type: ", evtType, "with args", fn, others) break; } }, works: 0.01, notes: [ `"chat:message" is the only available event.` ] }, createObj: { _ (objType, obj, ...others) { switch (objType) { case "path": { const page = d20.Campaign.pages._byId[obj._pageid]; obj.scaleX = obj.scaleX || 1; obj.scaleY = obj.scaleY || 1; obj.path = obj.path || obj._path return page.thepaths.create(obj) break; } default: console.error("Unhandled object type: ", objType, "with args", obj, others) break; } }, works: 0.01, notes: [ `Only supports "path" obects.` ] }, sendChat: { // TODO lift code from doChatInput _ (speakingAs, input, callback, options) { const message = { who: speakingAs, type: "general", content: input, playerid: window.currentPlayer.id, avatar: null, inlinerolls: [] }; const key = d20.textchat.chatref.push().key(); d20.textchat.chatref.child(key).setWithPriority(message, Firebase.ServerValue.TIMESTAMP) }, works: 0.01, notes: [ `speakingAs: String only.`, `input: String only.`, `callback: Unimplemented.`, `options: Unimplemented.`, `Messages are always sent with the player ID of the QPI user.` ] }, // findObjs: { // _ (attrs) { // // TODO // // const getters = { // // attribute: () => {}, // // character: () => {}, // // handout: () => {} // // }; // // const getAll = () => { // // const out = []; // // Object.values(getters).forEach(fn => out.push(...fn())); // // return out; // // }; // // // let out = attrs._type ? getters[attrs._type]() : getAll(); // // throw new Error("findObjs is unimplemented!"); // }, // works: 0.00, // notes: [ // `Unimplemented.` // ] // } }, _loadedScripts: null, async _init () { Object.keys(qpi._).forEach(k => { const it = qpi._[k]; if (it._preInit) it._preInit(); window[k] = it._; }); qpi._loadedScripts = await StorageUtil.pGet("VeQpi") || {}; $(`body`).append(`



Note that this tool is a for-testing faceplate over some internal code. It is intended for internal use only.
`); $(`#qpi-manager`).dialog({ autoOpen: false, resizable: true, width: 800, height: 600, }); $(`body`).append(`
`); $(`#qpi-manager-readme`).dialog({ autoOpen: false, resizable: true, width: 800, height: 600, }); qpi._log("Initialised!"); }, man (name) { if (!name) { qpi._log(`Showing all...\n==== Available API Mimics ====\n - ${Object.keys(qpi._).join("()\n - ")}()`); return; } const found = Object.keys(qpi._).find(k => k === name); if (!found) qpi._log(`No mimic with ${name} found -- perhaps it's unimplemented?`); else { const it = qpi._[found]; qpi._log(`Showing "${name}"...\n==== ${name} :: ${it.works * 100}% functional ====\n${(it.notes || []).join("\n")}`); } }, _manHtml () { let stack = ""; Object.keys(qpi._).forEach(k => { stack += `
${k}
`; const it = qpi._[k]; stack += `

Estimated ${it.works * 100}% functional
${(it.notes || []).join("
")}

`; }); return stack; }, _openManager () { const $win = $(`#qpi-manager`); $win.find(`.qpi-help`).off("click").on("click", () => { const $winReadme = $(`#qpi-manager-readme`); $winReadme.dialog("open"); $winReadme.find(`.qpi-readme`).html(qpi._manHtml()); }); $win.find(`.qpi-add-url`).off("click").on("click", () => { const url = $win.find(`.qpi-url`).val(); if (url && script.trim()) { qpi._log(`Attempting to load: "${url}"`); d20plus.js.pLoadWithRetries( url, url, (data) => { d20plus.js._addScript(url, data).then(() => { alert("Loaded successfully!"); $win.find(`.qpi-url`).val(""); }).catch(() => { alert("Failed to load script! See the console for more details (CTRL-SHIFT-J on Chrome)"); }); } ) } else { alert("Please enter a URL!"); } }); $win.find(`.qpi-add-text`).off("click").on("click", () => { const name = $win.find(`.qpi-name`).val(); const script = $win.find(`.qpi-text`).val(); if (name && script && name.trim() && script.trim()) { qpi._log(`Attempting to eval user script: ${name}`); d20plus.js._addScript(name, script).then(() => { alert("Loaded successfully!"); $win.find(`.qpi-name`).val(""); $win.find(`.qpi-text`).val(""); }).catch(() => { alert("Failed to load script! See the console for more details (CTRL-SHIFT-J on Chrome)"); }); } else { alert("Please enter a name and some code!"); } }); $win.dialog("open"); }, _log (...args) { console.log("%cQPI > ", "color: #ff00ff; font-size: large", ...args); } }; window.qpi = qpi; d20plus.qpi = {}; d20plus.qpi.pInitMockApi = async () => { // TODO check if this needs to be enabled for players too d20plus.ut.log("Initialising mock API"); await qpi._init(); }; } SCRIPT_EXTENSIONS.push(baseQpi); // Borrowed with <3 from Stormy's JukeboxIO function baseJukebox () { d20plus.jukebox = { playPlaylist (playlistId) { $(document) .find(`#jukeboxfolderroot .dd-folder[data-globalfolderid="${playlistId}"]`) .find("> .dd-content .play[data-isplaying=false]") .trigger("click"); }, playTrack (trackId) { $(document) .find(`#jukeboxfolderroot .dd-item[data-itemid="${trackId}"]`) .find("> .dd-content .play[data-isplaying=false]") .trigger("click"); }, stopPlaylist (playlistId) { $(document) .find(`#jukeboxfolderroot .dd-folder[data-globalfolderid="${playlistId}"]`) .find("> .dd-content .play[data-isplaying=true]") .trigger("click"); }, stopTrack (trackId) { $(document) .find(`#jukeboxfolderroot .dd-item[data-itemid="${trackId}"]`) .find("> .dd-content .play[data-isplaying=true]") .trigger("click"); }, play (id) { d20plus.jukebox.playPlaylist(id); d20plus.jukebox.playTrack(id); }, stop (id) { d20plus.jukebox.stopPlaylist(id); d20plus.jukebox.stopTrack(id); }, stopAll () { d20.jukebox.stopAllTracks(); }, skip () { const playlistId = d20plus.jukebox.getCurrentPlayingPlaylist(); d20.jukebox.stopAllTracks(); d20plus.jukebox.playPlaylist(playlistId); }, getCurrentPlayingTracks () { let playlingTracks = []; window.Jukebox.playlist.each((track) => { if (track.get("playing")) { playlingTracks.push(track.attributes); } }); return playlingTracks; }, getCurrentPlayingPlaylist () { const id = d20.Campaign.attributes.jukeboxplaylistplaying; return id ? id.split("|")[0] : id; }, addJukeboxChangeHandler (func) { d20plus.jukebox.addPlaylistChangeHandler(func); d20plus.jukebox.addTrackChangeHandler(func); }, addPlaylistChangeHandler (func) { d20.Campaign.on("change:jukeboxplaylistplaying change:jukeboxfolder", func); }, addTrackChangeHandler (func) { window.Jukebox.playlist.each((track) => { track.on("change:playing", func); }); }, getJukeboxFileStructure () { d20plus.jukebox.forceJukeboxRefresh(); return window.d20.jukebox.lastFolderStructure; }, getTrackById (id) { return window.Jukebox.playlist.get(id); }, getJukeboxPlaylists () { const fs = d20plus.jukebox.getJukeboxFileStructure(); const retVals = []; for (const fsItem of fs) { if (typeof (fsItem) === "string") continue; const rawPlaylist = fsItem; const playlist = { name: rawPlaylist.n, mode: rawPlaylist.s, tracks: [], }; for (const trackId of rawPlaylist.i) { const track = d20plus.jukebox.getTrackById(trackId); if (!track) { console.warn(`Tried to get track id ${trackId} but the query returned a falsy value. Skipping`); continue; } playlist.tracks.push(track); } retVals.push(playlist); } return retVals; }, getJukeboxTracks () { const fs = d20plus.jukebox.getJukeboxFileStructure(); const retVals = []; for (const fsItem of fs) { if (typeof (fsItem) !== "string") continue; const track = d20plus.jukebox.getTrackById(fsItem); if (!track) { console.warn(`Tried to get track id ${fsItem} but the query returned a falsy value. Skipping`); continue; } retVals.push(track); } return retVals; }, _getExportableTrack (s) { return { loop: s.attributes.loop, playing: s.attributes.playing, softstop: s.attributes.softstop, source: s.attributes.source, tags: s.attributes.tags, title: s.attributes.title, track_id: s.attributes.track_id, volume: s.attributes.volume, }; }, getExportablePlaylists () { return d20plus.jukebox.getJukeboxPlaylists().map(p => { return { name: p.name, mode: p.mode, tracks: p.tracks.map(d20plus.jukebox._getExportableTrack), }; }); }, getExportableTracks () { return d20plus.jukebox.getJukeboxTracks().map(d20plus.jukebox._getExportableTrack); }, importWrappedData (data) { d20plus.jukebox.forceJukeboxRefresh(); const tracks = (data.tracks || []).map(t => d20plus.jukebox.createTrack(t).id); const playlists = (data.playlists || []).map(p => { const trackIds = p.tracks.map(s => d20plus.jukebox.createTrack(s).id); return d20plus.jukebox.makePlaylistStructure(p.name, p.mode, trackIds); }); let fs = JSON.parse(d20.Campaign.attributes.jukeboxfolder); fs = fs.concat(tracks, playlists); d20.Campaign.save({ jukeboxfolder: JSON.stringify(fs) }); }, createTrack (data) { return window.Jukebox.playlist.create(data); }, makePlaylistStructure (name, mode, trackIds) { return { id: window.generateUUID(), n: name, s: mode, i: trackIds || [] }; }, forceJukeboxRefresh () { const $jukebox = $("#jukebox"); const serializable = $jukebox.find("#jukeboxfolderroot").nestable("serialize"); serializable && d20.Campaign.save({ jukeboxfolder: JSON.stringify(serializable) }); } }; } SCRIPT_EXTENSIONS.push(baseJukebox); function baseMath () { d20plus.math = { vec2: { /** * Normalize a 2d vector. * @param out Result storage * @param a Vector to normalise */ normalize (out, a) { const x = a[0], y = a[1]; let len = x*x + y*y; if (len > 0) { len = 1 / Math.sqrt(len); out[0] = a[0] * len; out[1] = a[1] * len; } return out; }, /** * Scale a 2d vector. * @param out Resulst storage * @param a Vector to scale * @param b Value to scale by */ scale (out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; return out; }, /** * Rotate a 2D vector * @param {vec2} out The receiving vec2 * @param {vec2} a The vec2 point to rotate * @param {vec2} b The origin of the rotation * @param {Number} c The angle of rotation * @returns {vec2} out */ rotate (out, a, b, c) { //Translate point to the origin let p0 = a[0] - b[0], p1 = a[1] - b[1], sinC = Math.sin(c), cosC = Math.cos(c); //perform rotation and translate to correct position out[0] = p0*cosC - p1*sinC + b[0]; out[1] = p0*sinC + p1*cosC + b[1]; return out; }, /** * Adds two vec2's * * @param {vec2} out the receiving vector * @param {vec2} a the first operand * @param {vec2} b the second operand * @returns {vec2} out */ add (out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; return out; }, /** * Subtracts vector b from vector a * * @param {vec2} out the receiving vector * @param {vec2} a the first operand * @param {vec2} b the second operand * @returns {vec2} out */ sub (out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; return out; }, /** * Computes the cross product of two vec2's * Note that the cross product must by definition produce a 3D vector * * @param {vec3} out the receiving vector * @param {vec2} a the first operand * @param {vec2} b the second operand * @returns {vec3} out */ cross (out, a, b) { let z = a[0] * b[1] - a[1] * b[0]; out[0] = out[1] = 0; out[2] = z; return out; }, /** * Multiplies two vec2's * * @param {vec2} out the receiving vector * @param {vec2} a the first operand * @param {vec2} b the second operand * @returns {vec2} out */ mult (out, a, b) { out[0] = a[0] * b[0]; out[1] = a[1] * b[1]; return out; }, /** * Calculates the length of a vec2 * * @param {vec2} a vector to calculate length of * @returns {Number} length of a */ len (a) { const x = a[0], y = a[1]; return Math.sqrt(x * x + y * y); } }, /** * Helper function to determine whether there is an intersection between the two polygons described * by the lists of vertices. Uses the Separating Axis Theorem * * @param a an array of connected points [[x, y], [x, y],...] that form a closed polygon * @param b an array of connected points [[x, y], [x, y],...] that form a closed polygon * @return boolean true if there is any intersection between the 2 polygons, false otherwise */ doPolygonsIntersect (a, b) { const polygons = [a, b]; let minA, maxA, projected, i, i1, j, minB, maxB; for (i = 0; i < polygons.length; i++) { // for each polygon, look at each edge of the polygon, and determine if it separates // the two shapes const polygon = polygons[i]; for (i1 = 0; i1 < polygon.length; i1++) { // grab 2 vertices to create an edge const i2 = (i1 + 1) % polygon.length; const p1 = polygon[i1]; const p2 = polygon[i2]; // find the line perpendicular to this edge const normal = [p2[1] - p1[1], p1[0] - p2[0]]; minA = maxA = undefined; // for each vertex in the first shape, project it onto the line perpendicular to the edge // and keep track of the min and max of these values for (j = 0; j < a.length; j++) { projected = normal[0] * a[j][0] + normal[1] * a[j][1]; if (minA === undefined || projected < minA) minA = projected; if (maxA === undefined || projected > maxA) maxA = projected; } // for each vertex in the second shape, project it onto the line perpendicular to the edge // and keep track of the min and max of these values minB = maxB = undefined; for (j = 0; j < b.length; j++) { projected = normal[0] * b[j][0] + normal[1] * b[j][1]; if (minB === undefined || projected < minB) minB = projected; if (maxB === undefined || projected > maxB) maxB = projected; } // if there is no overlap between the projects, the edge we are looking at separates the two // polygons, and we know there is no overlap if (maxA < minB || maxB < minA) { return false; } } } return true; } }; } SCRIPT_EXTENSIONS.push(baseMath); function baseConfig() { d20plus.cfg = {current: {}}; d20plus.cfg.pLoadConfigFailed = false; d20plus.cfg.pLoadConfig = async () => { d20plus.ut.log("Reading Config"); let configHandout = d20plus.cfg.getConfigHandout(); if (!configHandout) { d20plus.ut.log("No config found! Initialising new config..."); await d20plus.cfg.pMakeDefaultConfig(); } configHandout = d20plus.cfg.getConfigHandout(); if (configHandout) { configHandout.view.render(); return new Promise(resolve => { configHandout._getLatestBlob("gmnotes", async function (gmnotes) { try { const decoded = decodeURIComponent(gmnotes); d20plus.cfg.current = JSON.parse(decoded); d20plus.ut.log("Config Loaded:"); d20plus.ut.log(d20plus.cfg.current); resolve(); } catch (e) { console.error(e); if (!d20plus.cfg.pLoadConfigFailed) { // prevent infinite loops d20plus.cfg.pLoadConfigFailed = true; d20plus.ut.log("Corrupted config! Rebuilding..."); await d20plus.cfg.pMakeDefaultConfig(); await d20plus.cfg.pLoadConfig(); resolve(); } else { // if the config fails, continue to load anyway resolve(); } } }); }); } else d20plus.ut.log("Failed to create config handout!"); }; d20plus.cfg.pLoadPlayerConfig = async () => { d20plus.ut.log("Reading player Config"); const loaded = await StorageUtil.pGet(`Veconfig`); if (!loaded) { d20plus.ut.log("No player config found! Initialising new config..."); const dfltConfig = d20plus.cfg.getDefaultConfig(); d20plus.cfg.current = Object.assign(d20plus.cfg.current, dfltConfig); await StorageUtil.pSet(`Veconfig`, d20plus.cfg.current); } else { d20plus.cfg.current = loaded; } d20plus.ut.log("Player config Loaded:"); d20plus.ut.log(d20plus.cfg.current); }; d20plus.cfg.pMakeDefaultConfig = () => { return new Promise(resolve => { d20.Campaign.handouts.create({ name: CONFIG_HANDOUT, archived: true }, { success: function (handout) { notecontents = "The GM notes contain config options saved between sessions. If you want to wipe your saved settings, delete this handout and reload roll20. If you want to edit your settings, click the \"Edit Config\" button in the Settings (cog) panel."; // default settings // token settings mimic official content; other settings as vanilla as possible const gmnotes = JSON.stringify(d20plus.cfg.getDefaultConfig()); handout.updateBlobs({notes: notecontents, gmnotes: gmnotes}); handout.save({notes: (new Date).getTime(), inplayerjournals: ""}); resolve(); } }); }); }; d20plus.cfg.getConfigHandout = () => { d20plus.ut.getJournalFolderObj(); // ensure journal init return d20.Campaign.handouts.models.find(function (handout) { return handout.attributes.name === CONFIG_HANDOUT; }); }; d20plus.cfg.getCfgKey = (group, val) => { if (val === undefined || d20plus.cfg.current[group] === undefined) return undefined; const gr = d20plus.cfg.current[group]; for (const key of Object.keys(d20plus.cfg.current[group])) { if (gr[key] !== undefined && gr[key] === val) { return key; } } return undefined; }; d20plus.cfg.getRawCfgVal = (group, key) => { if (d20plus.cfg.current[group] === undefined) return undefined; if (d20plus.cfg.current[group][key] === undefined) return undefined; return d20plus.cfg.current[group][key]; }; d20plus.cfg.get = (group, key) => { if (d20plus.cfg.current[group] === undefined) return undefined; if (d20plus.cfg.current[group][key] === undefined) return undefined; if (CONFIG_OPTIONS[group][key]._type === "_SHEET_ATTRIBUTE") { if (!NPC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]]) return undefined; return NPC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]][d20plus.sheet]; } if (CONFIG_OPTIONS[group][key]._type === "_SHEET_ATTRIBUTE_PC") { if (!PC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]]) return undefined; return PC_SHEET_ATTRIBUTES[d20plus.cfg.current[group][key]][d20plus.sheet]; } return d20plus.cfg.current[group][key]; }; d20plus.cfg.getDefault = (group, key) => { return d20plus.cfg._getProp("default", group, key); }; d20plus.cfg.getPlaceholder = (group, key) => { return d20plus.cfg._getProp("_placeholder", group, key); }; d20plus.cfg._getProp = (prop, group, key) => { if (CONFIG_OPTIONS[group] === undefined) return undefined; if (CONFIG_OPTIONS[group][key] === undefined) return undefined; return CONFIG_OPTIONS[group][key][prop]; }; d20plus.cfg.getOrDefault = (group, key) => { if (d20plus.cfg.has(group, key)) return d20plus.cfg.get(group, key); return d20plus.cfg.getDefault(group, key); }; d20plus.cfg.getCfgEnumVals = (group, key) => { if (CONFIG_OPTIONS[group] === undefined) return undefined; if (CONFIG_OPTIONS[group][key] === undefined) return undefined; return CONFIG_OPTIONS[group][key].__values }; d20plus.cfg.getCfgSliderVals = (group, key) => { if (CONFIG_OPTIONS[group] === undefined) return undefined; if (CONFIG_OPTIONS[group][key] === undefined) return undefined; const it = CONFIG_OPTIONS[group][key]; return { min: it.__sliderMin, max: it.__sliderMax, step: it.__sliderStep } }; d20plus.cfg.getDefaultConfig = () => { const outCpy = {}; $.each(CONFIG_OPTIONS, (sectK, sect) => { if (window.is_gm || sect._player) { outCpy[sectK] = outCpy[sectK] || {}; $.each(sect, (k, data) => { if (!k.startsWith("_") && (window.is_gm || data._player)) { outCpy[sectK][k] = data.default; } }); } }); return outCpy; }; // Helpful for checking if a boolean option is set even if false d20plus.cfg.has = (group, key) => { if (d20plus.cfg.current[group] === undefined) return false; return d20plus.cfg.current[group][key] !== undefined; }; d20plus.cfg.setCfgVal = (group, key, val) => { if (d20plus.cfg.current[group] === undefined) d20plus.cfg.current[group] = {}; d20plus.cfg.current[group][key] = val; }; d20plus.cfg.makeTabPane = ($addTo, headers, content) => { if (headers.length !== content.length) throw new Error("Tab header and content length were not equal!"); if ($addTo.attr("hastabs") !== "YES") { const $tabBar = $(`