Forged20

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');
  }

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。