您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows rolling and sending abilities from forge steel character sheets into roll20.
// ==UserScript== // @name Forged20 // @namespace jackpoll4100 // @version 2.0 // @description Allows rolling and sending abilities from forge steel character sheets into roll20. // @author jackpoll4100 // @match https://andyaiken.github.io/forgesteel* // @match https://app.roll20.net/* // @match https://*.discordsays.com/* // @icon https://raw.githubusercontent.com/jackpoll4100/Forged20/refs/heads/main/DS%20logo.png // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // ==/UserScript== (function() { 'use strict'; function loadDependency(filename) { var fileref = document.createElement('script'); fileref.setAttribute("type", "text/javascript"); fileref.setAttribute("src", filename); if (typeof fileref != "undefined"){ (document.getElementsByTagName("head")[0] || document.documentElement).appendChild(fileref); } } if (!window.location.href.includes('forgesteel')){ window.forgesteelEnabled = false; function forgesteelToggle(){ window.forgesteelEnabled = !window.forgesteelEnabled; }; let forgesteelSettingsTemplate = `<div id="forgesteelSettings" style="display: flex; flex-direction: row; justify-content: space-between;"> <input type="checkbox" id="forgesteelEnabled" title="Enables rolling from your Forge Steel character sheet in another tab."> <input id="autoCheckLabel" style="margin: 5px 5px 5px 5px; width: 90%" disabled value="Enable Forged20" type="text" title="Enables rolling and sending abilities from your Forge Steel character sheet in another tab."> </div>`; function GM_onMessage(label, callback){ GM_addValueChangeListener(label, function(){ callback.apply(undefined, arguments[2]); }); } function execMacro(macro){ console.log('Forge Steel - Executing Macro: ', macro); if (!window.forgesteelEnabled){ console.log('cancelling macro execution, forgesteel connection not enabled.'); return; } document.querySelectorAll('[title="Text Chat Input"]')[0].value = macro; document.getElementById('chatSendBtn').click(); } function execImport(jsonCharacter) { if(document.querySelector('[name="attr_forgesteel_json"]')) { document.querySelector('[name="attr_forgesteel_json"]').value = jsonCharacter; setTimeout(()=> { document?.querySelector('[name="act_import"]')?.click(); }, 200); } } GM_onMessage('forgesteel-pipe', function(message) { console.log('forgesteel message received: ', message); if (message.includes('charactersheet---')) { if(window.location.href.includes('/character')) { let cleanedString = message.split('charactersheet---')[1]; execImport(cleanedString); } } else if (message.includes('template') || message.includes('data:image')){ let cleanedString = message.split('---')[1]; execMacro(cleanedString); } }); if (window.location.href.includes('/character')) { const bodyTarget = document.querySelector('body'); const config = { attributes: false, childList: true, subtree: true }; function GM_sendMessage(label){ GM_setValue(label, Array.from(arguments).slice(1)); } console.log('sending open message'); GM_sendMessage('roll20-pipe', 'roll20 opened'); const listenerSetup = ()=>{ const foundElement = document?.querySelector('[name="act_import"]'); if (foundElement && !foundElement.getAttribute('listener-applied')){ foundElement.setAttribute('listener-applied', true); foundElement.parentElement.innerHTML += '<button type="button" id="f20-import">Import directly from Forge Steel</button>'; document.getElementById('f20-import')?.addEventListener?.('click', ()=> { console.log(`requesting character from forgesteel`); GM_sendMessage(`roll20-pipe`, `${ Math.random() }---character please`); }); } }; const observer = new MutationObserver(listenerSetup); observer.observe(bodyTarget, config); } function appendForgeSteelSettings(){ let uiContainer = document.createElement('div'); uiContainer.innerHTML = forgesteelSettingsTemplate; document.getElementById('textchat-input').appendChild(uiContainer); document.getElementById('forgesteelEnabled').addEventListener('click', forgesteelToggle); } function timer (){ if (document.getElementById('chatSendBtn')){ appendForgeSteelSettings(); } else{ setTimeout(timer, 500); } } setTimeout(timer, 0); console.log('forgesteel listener registered'); } else { function GM_sendMessage(label){ GM_setValue(label, Array.from(arguments).slice(1)); } console.log('sending open message'); GM_sendMessage('forgesteel-pipe', 'forgesteel opened'); let classMap = { rollSelector: '.total > div.ant-statistic-content > span > span', rollTitles: ['.roll-modal div.ant-statistic-title', '.modal-content .ability-panel > div.header-text-panel > div > div.header-text'], abilityTypeSelector: '.ability-modal .ability-info-panel > .ds-text', rollModifierSelector: '.ability-modal .roll-state-selector .ant-segmented-item-selected > .ant-segmented-item-label', pointSelector: '.ability-modal .header-text-content .pill', keywordsSelector: '.ability-modal .ability-panel .ant-tag', distanceTargetSelector: '.ability-modal .ability-info-panel > .field > .field-value', rawDiceValuesSelector: '.ability-modal .result-row .ant-statistic-content-value-int', effectDescriptionsSelector: '.ability-modal .ability-panel > div > div:not(.power-roll-panel) > p', specialEffectDescriptionsSelector: '.ability-modal .ability-panel > div > div.field.horizontal', flavorSelector: '.ability-modal .ability-description-text > p', tiersSelector: '.ability-modal .ability-panel div.power-roll-panel p', rollButton: '.die-roll-panel > .ant-btn', effectsSelector: '.ant-drawer .power-roll-row .effect', criticalSuccess: '.ant-alert-success', tierAlert: '.ant-alert-warning .ant-alert-message', modalSelectors: ['.modal-content .ability-section', '.modal-content .feature-modal', '.modal-content .roll-modal'], hideOutputSelectors: ['.ant-collapse', '.ant-slider'], modalOpen: ['.modal'] }; function fetchCharacter(callback) { let request = window.indexedDB.open('localforage'); request.onsuccess = async function(event) { let db = event.target.result; let store = db.transaction(['keyvaluepairs'],'readwrite').objectStore('keyvaluepairs'); store.get('forgesteel-heroes').onsuccess = function (event) { console.log('Found the following characters: ', event.target.result); const urlTokens = window.location.href.split('/'); let stopLooking = false; for (const character of event.target.result) { if (character.id == urlTokens[urlTokens.length - 1] && !stopLooking) { stopLooking = true; callback(character); } } }; }; } function GM_onMessage(label, callback){ GM_addValueChangeListener(label, function(){ callback.apply(undefined, arguments[2]); }); } GM_onMessage('roll20-pipe', function(message) { console.log('roll20 message received: ', message); if (message?.split?.('---')?.[1] === 'character please') { fetchCharacter((character)=> { const characterJSON = JSON.stringify(character); console.log('Sending character to roll20: ', characterJSON); GM_sendMessage('forgesteel-pipe', `${ Math.random() }---charactersheet---` + characterJSON); }); } }); function rasterizeContent(event) { const cleanDOM = (el) => { if (el.querySelector('[listener-applied="true"]')?.style?.display === "") { el.querySelector('[listener-applied="true"]').style.display = 'none'; } for (let s of classMap.hideOutputSelectors) { for (let e of el.querySelectorAll(s)) { e.style.display = 'none'; } } }; for (let m of classMap.modalSelectors) { const element = event.target.closest(m) || document.querySelector(m); if (element) { const elCopy = element.cloneNode(true); elCopy.id = 'tmp-ability-copy'; let padding = ''; if (m.includes('feature-panel')) { padding = ' padding: 10px;'; } elCopy.setAttribute('style', `font-size: 1.4rem !important; z-index: -1; position: absolute; width: 100%; ${ padding }`); elCopy.innerHTML += '<style>#tmp-ability-copy * { font-size: 1.4rem; } #tmp-ability-copy .pill { min-width: 60px; }</style>' cleanDOM(elCopy); element.parentNode.appendChild(elCopy); domtoimage.toJpeg(document.getElementById('tmp-ability-copy'), { style: { 'background-color': '#e6e6e6' } }).then((dataUrl) => { document.getElementById('tmp-ability-copy').remove(); GM_sendMessage('forgesteel-pipe', `${ Math.random() }---` + `[x](${ dataUrl }#.png)`); }); return; } } } function rollWatcher(e){ rasterizeContent(e); return; // The photo rasterizing method makes the existing scraping code redundant, but keeping it here to refer to if needed. fetchCharacter((character) => { const roll = document.querySelector(classMap.rollSelector)?.innerHTML; const rawRoll = parseInt(document.querySelectorAll(classMap.rawDiceValuesSelector)?.[0]?.innerHTML || 0) + parseInt(document.querySelectorAll(classMap.rawDiceValuesSelector)?.[1]?.innerHTML || 0); let rollTitle = ''; classMap.rollTitles.forEach((selector)=>{ rollTitle = document.querySelector(selector)?.innerHTML ? document.querySelector(selector).innerHTML : rollTitle; }); let modifierText = ''; const tierAlert = document.querySelector(classMap.tierAlert); let rollTier = roll < 12 ? 1 : roll < 17 ? 2 : 3; if (tierAlert?.innerHTML?.includes('down') && rollTier > 1){ rollTier --; modifierText = '(Tier was decreased by a Double Bane)'; } else if (tierAlert?.innerHTML?.includes('up') && rollTier < 3){ rollTier ++; modifierText = '(Tier was increased by a Double Edge)'; } const rollMode = 'new'; if(rollMode === 'legacy' || document.querySelector(classMap.rollTitles[0])?.innerHTML) { // "Legacy" macro construction. const effects = document.querySelectorAll(classMap.effectsSelector); const constructedEffect = effects.length === 3 ? `{{effect=${ effects[rollTier - 1].innerHTML } ${ modifierText }}}` : ''; const constructedMessage = `&{template:default} {{name=${ character?.name ? `${ character.name }` : '' }}} ${ rollTitle ? `{{type=${ rollTitle }}}` : '' } {{result=${ roll } ${ document.querySelector(classMap.criticalSuccess) ? '(Critical Success)' : ''}}} ${ constructedEffect }`; console.log('Sending message to roll20: ', constructedMessage); GM_sendMessage('forgesteel-pipe', `${ Math.random() }---` + constructedMessage); return; } let macroTemplate = `&{template:default} {{Resources Required=RESOURCES}} {{Keywords=KEYWORDS}} {{Type=ABILITYTYPE}} {{Distance=DISTANCE}} {{Target=TARGET}} {{Power Roll=[POWERROLL]POWERCSS}} {{Tier 1=TIER1}} {{Tier 2=TIER2}} {{Tier 3=TIER3}}{{name=[NAME](NAMECSS display: inline;)%NEWLINE%%NEWLINE%[FLAVOR](FLAVORCSS display: inline;)}} {{Effect=EFFECT}} {{SPECIALEFFECTS}} {{Edges and Banes=MODIFIER}}`; // Apply Special Effects const specialEffects = document.querySelectorAll(classMap.specialEffectDescriptionsSelector); let specialEffectsString = ''; specialEffects.forEach(e => { const seName = e.querySelector('.field-label > div')?.innerHTML?.includes('<') ? e.querySelector('.field-label > div')?.innerHTML?.split?.('<')?.[0] : e.querySelector('.field-label')?.innerHTML; const sePoints = e.querySelector('.field-label .pill')?.innerHTML; const seBody = e.querySelector('.field-value p')?.innerHTML; specialEffectsString += `{{${ seName + (sePoints ? ` (${ sePoints?.replaceAll(' ', '') })` : '') }=${ seBody }}}`; }); macroTemplate = macroTemplate.replaceAll('{{SPECIALEFFECTS}}', specialEffectsString); // Apply Tier Upgrades if (rawRoll < 19) { macroTemplate = macroTemplate.replaceAll(`TIER${ rollTier }`, `[>>]POWERCSS [TIER${ rollTier }](HEADERCSS display: inline)[<<]POWERCSS`); } else { macroTemplate = macroTemplate.replaceAll(`TIER${ rollTier }`, `[>>]POWERCSS [Critical Hit!]POWERCSS [TIER${ rollTier }](HEADERCSS display: inline)[<<]POWERCSS`); macroTemplate += '{{Extra=[Take Another Action!]CRITCSS}}'; } // Apply inline styles const powercss = '(" style=" color: yellow; font-weight: bold; text-shadow: 2px 2px black, -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; font-size: 15px; cursor: text;)'; const headercss = '" style="text-decoration: none; background: none; padding: 0px; font-size: 13px; cursor: text; display:none; border: none; text-shadow: 2px 2px black, -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; color: Yellow; font-weight: bold; white-space: wrap;'; const namecss = '" style="text-decoration: none; background: none; padding: 0px; font-size: 13px; font-weight: bold; cursor: text; display:none; border: none; text-shadow: 2px 2px black, -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; color: white; cursor: text; font-style: bold; font-size: 20px;'; const flavorcss = '" style="text-decoration: none; background: none; padding: 0px; font-size: 13px; font-weight: bold; cursor: text; display:none; border: none; text-shadow: 2px 2px black, -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; color: white; cursor: text; font-style: italic; font-size: 11px;'; const critcss = '(#" style=" font-weight: normal; display: block; color: yellow; font-weight: bold; text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; cursor: text;)'; macroTemplate = macroTemplate.replaceAll('POWERCSS', powercss).replaceAll('HEADERCSS', headercss).replaceAll('NAMECSS', namecss).replaceAll('FLAVORCSS', flavorcss).replaceAll('CRITCSS', critcss); // Apply Name macroTemplate = macroTemplate.replaceAll('NAME', `${ character?.name ? `${ character.name }` : '' } - ${ rollTitle }`); // Apply Power Roll macroTemplate = macroTemplate.replaceAll('POWERROLL', roll); // Apply Resources const pointString = document.querySelector(classMap.pointSelector)?.innerHTML; macroTemplate = macroTemplate.replaceAll('{{Resources Required=RESOURCES}}', (pointString && !pointString?.includes('Signature')) ? `{{Resources Required=${ pointString }}}` : ''); // Apply Distance and Target const distanceTargetArr = document.querySelectorAll(classMap.distanceTargetSelector); macroTemplate = macroTemplate.replaceAll('DISTANCE', distanceTargetArr?.[0]?.innerHTML || 'N/A'); macroTemplate = macroTemplate.replaceAll('TARGET', distanceTargetArr?.[1]?.innerHTML || distanceTargetArr?.[0]?.innerHTML || 'N/A'); // Apply Tiers const tiersArr = document.querySelectorAll(classMap.tiersSelector); macroTemplate = macroTemplate.replaceAll('TIER1', tiersArr?.[0]?.innerHTML || 'N/A'); macroTemplate = macroTemplate.replaceAll('TIER2', tiersArr?.[1]?.innerHTML || 'N/A'); macroTemplate = macroTemplate.replaceAll('TIER3', tiersArr?.[2]?.innerHTML || 'N/A'); // Apply Flavor const flavor = document.querySelector(classMap.flavorSelector)?.innerHTML; macroTemplate = macroTemplate.replaceAll('FLAVOR', flavor || 'N/A'); // Apply Type const type = document.querySelector(classMap.abilityTypeSelector)?.innerHTML; macroTemplate = macroTemplate.replaceAll('ABILITYTYPE', type || 'N/A'); // Apply Effects const effects = document.querySelectorAll(classMap.effectDescriptionsSelector); let effectsString = ''; effects.forEach(e => { effectsString += e.innerHTML + ' '; }); macroTemplate = macroTemplate.replaceAll('{{Effect=EFFECT}}', effectsString ? `{{Effect=${effectsString}}}` : ''); // Apply Keywords const keywords = document.querySelectorAll(classMap.keywordsSelector); let keywordsString = ''; keywords.forEach(k => { keywordsString += k.innerHTML + ' '; }); macroTemplate = macroTemplate.replaceAll('KEYWORDS', keywordsString || 'N/A'); // Apply Modifier const modifier = document.querySelector(classMap.rollModifierSelector)?.innerHTML; macroTemplate = macroTemplate.replaceAll('MODIFIER', (modifier && modifier !== 'Standard Roll') ? modifier : 'None' ); // Send to chat console.log('Sending message to roll20: ', macroTemplate); GM_sendMessage('forgesteel-pipe', `${ Math.random() }---` + macroTemplate); }); } const sendButtonTemplate = ` <style> #roll20-send-button{ position: absolute; right: 5px; bottom: 5px; border-radius: 8px; border: 0px; padding: 10px; background-color: rgb(22,119,255); color: white; cursor: pointer; } #roll20-send-button:hover{ zoom:1.1; }</style> <div id="roll20-send-button">Send to Roll20</div> `; const bodyTarget = document.querySelector('body'); const config = { attributes: false, childList: true, subtree: true }; const listenerSetup = ()=>{ const foundElement = document.querySelector(classMap.rollButton); if (foundElement && !foundElement.getAttribute('listener-applied')){ foundElement.setAttribute('listener-applied', true); foundElement.addEventListener('click', (e)=>{ setTimeout(()=>{ rollWatcher(e) }, 100); }); } const foundModal = document.querySelector(classMap.modalOpen); if (foundModal && !foundModal.getAttribute('button-applied')) { foundModal.setAttribute('button-applied', true); setTimeout(()=> { let applyButton = false; for (let m of classMap.modalSelectors) { const element = document.querySelector(m); if (element) { applyButton = true; } } if (applyButton) { let template = document.createElement('div'); template.innerHTML = sendButtonTemplate; foundModal.appendChild(template); document.getElementById('roll20-send-button').addEventListener('click', (e)=>{ rollWatcher(e); }); } }, 100); } }; const observer = new MutationObserver(listenerSetup); observer.observe(bodyTarget, config); loadDependency('https://cdn.jsdelivr.net/npm/[email protected]/dist/dom-to-image-more.min.js'); } })();