您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Improved UI for Roll20's FX Playground
// ==UserScript== // @name Roll20 FX Playground Tools // @namespace https://wiki.roll20.net/ // @version 0.2 // @description Improved UI for Roll20's FX Playground // @author Braiba // @match https://wiki.roll20.net/fxplayground/ // @grant none // @require http://code.jquery.com/jquery-3.6.0.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js // ==/UserScript== (function() { 'use strict'; var fields = [ { "name": "angle", "label": "Angle", "type": "number", "min": 0, "max": 360, "isOptional": true, "defaultValue": 0, "defaultRandom": 360 }, { "name": "duration", "label": "Duration", "type": "number", "min": 1, "isOptional": true, "defaultValue": -1 }, { "name": "emissionRate", "label": "Emission Rate", "type": "number", "min": 0, "defaultValue": 8 }, { "name": "gravity", "label": "Gravity", "type": "point", "defaultValue": {x: 0.4, y: 0.2} }, { "name": "lifeSpan", "label": "Life Span", "type": "number", "min": 0, "defaultValue": 9, "defaultRandom": 7 }, { "name": "maxParticles", "label": "Max Particles", "type": "number", "min": 1, "defaultValue": 150 }, { "name": "positionRandom", "label": "Position Random", "type": "point", "defaultValue": {x: 10, y: 10} }, { "name": "sharpness", "label": "Sharpness", "type": "number", "max": 100, "min": 0, "defaultValue": 40, "defaultRandom": 10 }, { "name": "size", "label": "Size", "type": "number", "min": 0, "defaultValue": 45, "defaultRandom": 15 }, { "name": "speed", "label": "Speed", "type": "number", "min": 0, "defaultValue": 5, "defaultRandom": 1.5 }, { "name": "startColour", "label": "Start Colour", "type": "colour", "defaultValue": [250, 218, 68, 1] }, { "name": "startColourRandom", "label": "Start Colour Random", "type": "colour-random", "defaultValue": [62, 60, 60, 0] }, { "name": "endColour", "label": "End Colour", "type": "colour", "defaultValue": [245, 35, 0, 0], "defaultRandom": [60, 60, 60, 0] }, { "name": "endColourRandom", "label": "End Colour Random", "type": "colour-random", "defaultValue": [60, 60, 60, 0] } ]; var builtInColours = [ { "name": "Acid", "values": { "startColour": [0, 35, 10, 1], "startColourRandom": [0, 10, 10, 0.25], "endColour": [0, 75, 30, 0], "endColourRandom": [0, 20, 20, 0] } }, { "name": "Blood", "values": { "startColour": [175, 0, 0, 1], "startColourRandom": [20, 0, 0, 0], "endColour": [175, 0, 0, 0], "endColourRandom": [20, 0, 0, 0] } }, { "name": "Charm", "values": { "startColour": [200, 40, 150, 1], "startColourRandom": [25, 5, 20, 0.25], "endColour": [200, 40, 150, 0], "endColourRandom": [50, 10, 40, 0] } }, { "name": "Death", "values": { "startColour": [10, 0, 0, 1], "startColourRandom": [5, 0, 0, 0.25], "endColour": [20, 0, 0, 0], "endColourRandom": [10, 0, 0, 0] } }, { "name": "Fire", "values": { "startColour": [220, 35, 0, 1], "startColourRandom": [62, 0, 0, 0.25], "endColour": [220, 35, 0, 0], "endColourRandom": [60, 60, 60, 0] } }, { "name": "Frost", "values": { "startColour": [90, 90, 175, 1], "startColourRandom": [0, 0, 0, 0.25], "endColour": [125, 125, 255, 0], "endColourRandom": [0, 0, 0, 0] } }, { "name": "Holy", "values": { "startColour": [175, 130, 25, 1], "startColourRandom": [20, 10, 0, 0.25], "endColour": [175, 130, 50, 0], "endColourRandom": [20, 20, 20, 0] } }, { "name": "Magic", "values": { "startColour": [50, 50, 50, 0.5], "startColourRandom": [150, 150, 150, 0.25], "endColour": [128, 128, 128, 0], "endColourRandom": [125, 125, 125, 0] } }, { "name": "Slime", "values": { "startColour": [0, 250, 50, 1], "startColourRandom": [0, 20, 10, 0.25], "endColour": [0, 250, 50, 0], "endColourRandom": [20, 20, 20, 0] } }, { "name": "Smoke", "values": { "startColour": [150, 150, 150, 1], "startColourRandom": [10, 10, 10, 0.5], "endColour": [200, 200, 200, 0], "endColourRandom": [10, 10, 10, 0] } }, { "name": "Water", "values": { "startColour": [15, 15, 150, 1], "startColourRandom": [5, 5, 25, 0.25], "endColour": [10, 10, 100, 0], "endColourRandom": [10, 10, 25, 0] } } ]; var builtInEffects = [ { "name": "Beam", "values": { "maxParticles": 3000, "size": 15, "sizeRandom": 0, "lifeSpan": 15, "lifeSpanRandom": 0, "emissionRate": 50, "speed": 30, "speedRandom": 7, "angle": -1, "angleRandom": 1, "duration": 25 } }, { "name": "Breath", "values": { "maxParticles": 750, "size": 20, "sizeRandom": 10, "lifeSpan": 25, "lifeSpanRandom": 2, "emissionRate": 25, "speed": 15, "speedRandom": 3, "angle": -1, "angleRandom": 30, "duration": 25 } }, { "name": "Bubbling", "values": { "maxParticles": 200, "size": 15, "sizeRandom": 3, "lifeSpan": 20, "lifeSpanRandom": 5, "speed": 7, "speedRandom": 2, "gravity": { "x": 0.01, "y": 0.65 }, "angle": 270, "angleRandom": 35, "emissionRate": 1 } }, { "name": "Burn", "values": { "maxParticles": 100, "size": 35, "sizeRandom": 15, "lifeSpan": 10, "lifeSpanRandom": 3, "speed": 3, "angle": 0, "emissionRate": 12 } }, { "name": "Burst", "values": { "maxParticles": 100, "size": 35, "sizeRandom": 15, "lifeSpan": 10, "lifeSpanRandom": 3, "speed": 3, "angle": 0, "emissionRate": 12, "onDeath": "explosion-magic" /* Not currently supported */ } }, { "name": "Explode", "values": { "maxParticles": 300, "size": 35, "sizeRandom": 10, "duration": 25, "lifeSpan": 20, "lifeSpanRandom": 5, "speed": 7, "speedRandom": 1, "angle": 0, "angleRandom": 360, "emissionRate": 300 } }, { "name": "Glow", "values": { "maxParticles": 500, "size": 5, "sizeRandom": 3, "lifeSpan": 17, "lifeSpanRandom": 5, "emissionRate": 7, "speed": 3, "speedRandom": 2, "angle": 270, "angleRandom": 45 } }, { "name": "Missile", "values": { "maxParticles": 350, "size": 7, "sizeRandom": 3, "lifeSpan": 7, "lifeSpanRandom": 5, "emissionRate": 50, "speed": 7, "speedRandom": 5, "angle": 135, "angleRandom": 0 } }, { "name": "Nova", "values": { "maxParticles": 500, "size": 15, "sizeRandom": 0, "lifeSpan": 30, "lifeSpanRandom": 0, "emissionRate": 1000, "speed": 7, "speedRandom": 0, "angle": 0, "angleRandom": 180, "duration": 5 } }, { "name": "Splatter", "values": { "maxParticles": 750, "size": 7, "sizeRandom": 3, "lifeSpan": 20, "lifeSpanRandom": 5, "emissionRate": 3, "speed": 7, "speedRandom": 2, "gravity": { "x": 0.01, "y": 0.5 }, "angle": -1, "angleRandom": 20, "duration": 10 } }, ]; var hexToRgb = function(hex) { hex = hex.replace(/^#/,''); var matches = hex.match(/[0-9a-f]{2}/gi); return [ parseInt(matches[0], 16), parseInt(matches[1], 16), parseInt(matches[2], 16) ]; } var numberToHex = function(n, digits) { return n.toString(16).padStart(digits, '0'); } var rgbaToHex = function(rgba, ignoreAlpha) { return '#' + numberToHex(rgba[0], 2) + numberToHex(rgba[1], 2) + numberToHex(rgba[2], 2) + (ignoreAlpha ? '' : numberToHex(rgba[3], 2)); } var addFieldValueToFx = function(fieldData, formData, fxData) { if (fieldData.type === 'number') { var numValue = -1; if (!fieldData.hasOwnProperty('isOptional') || !fieldData.isOptional || formData.hasOwnProperty(fieldData.name + 'Enabled')) { numValue = parseFloat(formData[fieldData.name]); if (numValue === NaN) { numValue = fieldData.defaultValue; } } if (numValue !== fieldData.defaultValue) { fxData[fieldData.name] = numValue; } if (fieldData.hasOwnProperty('defaultRandom')) { var randomName = fieldData.name + 'Random'; var randomValue = parseFloat(formData[randomName]); if (randomValue !== fieldData.defaultRandom) { fxData[randomName] = randomValue; } } } else if (fieldData.type === 'point') { var pointXValue = parseFloat(formData[fieldData.name + 'X']); var pointYValue = parseFloat(formData[fieldData.name + 'Y']); if ((pointXValue !== NaN) && (pointYValue !== NaN)) { var pointValue = {x: pointXValue, y: pointYValue}; if (JSON.stringify(pointValue) !== JSON.stringify(fieldData.defaultValue)) { fxData[fieldData.name] = pointValue; } } } else if (fieldData.type === 'colour') { var colValue = hexToRgb(formData[fieldData.name]); var colOpacity = parseFloat(formData[fieldData.name + 'Opacity']); if (colOpacity !== NaN) { colValue.push(colOpacity/100); } else { colValue.push(fieldData.defaultValue[3]); } if (JSON.stringify(colValue) !== JSON.stringify(fieldData.defaultValue)) { fxData[fieldData.name] = colValue; } } else if (fieldData.type === 'colour-random') { var parts = ['R', 'G', 'B', 'A']; var partValues = []; for (var i in parts) { if (parts.hasOwnProperty(i)) { var part = parts[i]; var partValue = parseFloat(formData[fieldData.name + part]); if (partValue === NaN) { partValue = fieldData.dataValue[i]; } partValues.push(partValue); } } if (JSON.stringify(partValues) !== JSON.stringify(fieldData.defaultValue)) { fxData[fieldData.name] = partValues; } } else { console.error('Unexpected field type: ' + fieldData.type); } } var buildFxData = function() { var formData = {}; for (var inputData of $('#controlsPlus').serializeArray()) { formData[inputData.name] = inputData.value; } var fxData = {}; for (var field of fields) { addFieldValueToFx(field, formData, fxData); } if (fxData.hasOwnProperty('gravity')) { // Zero values aren't allowed for gravity if (fxData.gravity.x === 0) { fxData.gravity.x = 0.001; } if (fxData.gravity.y === 0) { fxData.gravity.y = 0.001; } } return fxData; } var doRefreshPlus = function() { var fxData = buildFxData(); if (fxData.hasOwnProperty('gravity')) { // Gravity has an explicit override on the format in the playground code for some reason fxData.gravity = [fxData.gravity.x, fxData.gravity.y]; } $('#data').val(JSON.stringify(fxData)); $('#refresh').click(); } var addBuiltInLoader = function($controlsPlus) { var $row = $('<div class="form-row">'); var $colourSelect = $('<select></select>') .attr('id', 'builtInColor') .addClass('form-control'); for (var colourData of builtInColours) { $colourSelect.append($('<option>' + colourData.name + '</option>')); } var $effectSelect = $('<select></select>') .attr('id', 'builtInEffect') .addClass('form-control'); for (var effectData of builtInEffects) { $effectSelect.append($('<option>' + effectData.name + '</option>')); } var $loadBtn = $('<button type="button">Load Built-In</button>') .attr('id', 'loadButton') .addClass('btn btn-primary'); $row.append($('<div class="col"></div>').append($colourSelect)); $row.append($('<div class="col"></div>').append($effectSelect)); $row.append($('<div class="col-auto"></div>').append($loadBtn)); $controlsPlus.append($row); } var createFieldInput = function(fieldData, $row) { var initialValue = fieldData.defaultValue; var isEnabled = true; var fieldNames = []; if (fieldData.type === 'number') { var $numInput = $('<input />') .attr('type', 'number') .attr('name', fieldData.name) .attr('min', 0) .attr('step', 'any') .addClass('form-control'); if (fieldData.hasOwnProperty('max')) { $numInput.attr('max', fieldData.max); } $row.append($('<div class="col"></div>').append($numInput)); fieldNames.push(fieldData.name); if (fieldData.hasOwnProperty('defaultRandom')) { var $numRandomInput = $('<input />') .attr('type', 'number') .attr('name', fieldData.name + 'Random') .attr('min', (fieldData.hasOwnProperty('min') ? fieldData.min : 0)) .attr('step', 'any') .addClass('form-control'); if (fieldData.hasOwnProperty('max')) { $numRandomInput.attr('max', fieldData.max); } $row.append( $('<div class="col"></div>') .append( $('<div class="input-group"></div>') .append($('<div class="input-group-prepend"><div class="input-group-text">±</div></div>')) .append($numRandomInput) ) ); // Angle can be disabled, but still have randomness applied // fieldNames.push(fieldData.name + 'Random'); } } else if (fieldData.type === 'point') { var $xInput = $('<input />') .attr('type', 'number') .attr('name', fieldData.name + 'X') .addClass('form-control'); var $yInput = $('<input />') .attr('type', 'number') .attr('name', fieldData.name + 'Y') .addClass('form-control'); $row.append($('<div class="col"></div>').append($xInput)); $row.append($('<div class="col-auto"><div class="form-control-plaintext">,</div></div>')); $row.append($('<div class="col"></div>').append($yInput)); fieldNames.push(fieldData.name + 'X'); fieldNames.push(fieldData.name + 'Y'); } else if (fieldData.type === 'colour') { var $colInput = $('<input />') .attr('name', fieldData.name) .attr('type', 'color') .addClass('form-control'); var $colOpacityInput = $('<input />') .attr('name', fieldData.name + 'Opacity') .attr('type', 'number') .attr('min', 0) .attr('max', 100) .attr('step', 'any') .addClass('form-control'); $row.append($('<div class="col"></div>').append($colInput)); $row.append($('<div class="col-auto"><div class="form-control-plaintext">x</div></div>')); $row.append( $('<div class="col"></div>') .append( $('<div class="input-group"></div>') .append($colOpacityInput) .append($('<div class="input-group-append"><div class="input-group-text">%</div></div>')) ) ); fieldNames.push(fieldData.name); } else if (fieldData.type === 'colour-random') { var parts = ['R', 'G', 'B', 'A']; for (var i in parts) { if (parts.hasOwnProperty(i)) { var part = parts[i]; var $partInput = $('<input />') .attr('type', 'number') .attr('name', fieldData.name + part) .attr('min', 0) .attr('max', (part === 'A' ? 1 : 255)) .attr('step', 'any') .addClass('form-control') .addClass('colour-part-' + part.toLowerCase()); $row.append($('<div class="col"></div>').append($partInput)); } fieldNames.push(fieldData.name + part); } } else { console.error('Unexpected field type: ' + fieldData.type); } if (fieldData.hasOwnProperty('isOptional') && fieldData.isOptional) { var $enabledCheckbox = $('<input />') .attr('type', 'checkbox') .attr('name', fieldData.name + 'Enabled') .addClass('form-check-input') .data('linked-fields', fieldNames); if (isEnabled) { $enabledCheckbox.prop('checked', true); } $('label', $row).after($('<div class="col-auto"></div>').append($('<div class="form-check"></div>').append($enabledCheckbox))); } } var addFieldToForm = function($form, fieldData) { var html = ` <div class="form-group form-row"> <label class="col-5 col-form-label"> ${fieldData.label} </label> </div> `; var $row = $(html); createFieldInput(fieldData, $row); $form.append($row); } var getBuiltInValues = function() { var col = $('#builtInColor').val(); var colData = builtInColours.find(function (findColData) { return (findColData.name === col); }); var effect = $('#builtInEffect').val(); var effectData = builtInEffects.find(function (findEffectData) { return (findEffectData.name === effect); }); var values = {}; Object.assign(values, colData.values); Object.assign(values, effectData.values); return values; } var loadFieldFromObject = function(fieldData, obj) { var isEnabled = true; var fieldNames = []; if (fieldData.type === 'number') { var $numInput = $('[name=' + fieldData.name +']'); var initialValue = obj.hasOwnProperty(fieldData.name) ? obj[fieldData.name] : fieldData.defaultValue; if (initialValue === -1) { initialValue = 0; isEnabled = false; } $numInput.val(initialValue); $numInput.prop('disabled', !isEnabled); if (fieldData.hasOwnProperty('defaultRandom')) { var $numRandomInput = $('[name=' + fieldData.name +'Random]'); $numRandomInput.val(obj.hasOwnProperty(fieldData.name + 'Random') ? obj[fieldData.name + 'Random'] : fieldData.defaultRandom); } } else if (fieldData.type === 'point') { var $xInput = $('[name=' + fieldData.name +'X]'); var $yInput = $('[name=' + fieldData.name +'Y]'); if (obj.hasOwnProperty(fieldData.name)) { var fieldValues = obj[fieldData.name]; if (fieldValues.hasOwnProperty('x')) { // The FX playground expects the gravity property in the form of an array, but actual Roll20 needs it as an object with x and y keys, so support both on import $xInput.val(fieldValues.x); $yInput.val(fieldValues.y); } else { $xInput.val(fieldValues[0]); $yInput.val(fieldValues[1]); } } else { $xInput.val(fieldData.defaultValue.x); $yInput.val(fieldData.defaultValue.y); } } else if (fieldData.type === 'colour') { var $colInput = $('[name=' + fieldData.name +']'); var $colOpacityInput = $('[name=' + fieldData.name +'Opacity]'); var colValue = (obj.hasOwnProperty(fieldData.name) ? obj[fieldData.name] : fieldData.defaultValue); $colInput.val(rgbaToHex(colValue, true)); $colOpacityInput.val(colValue[3] * 100); } else if (fieldData.type === 'colour-random') { var parts = ['R', 'G', 'B', 'A']; for (var i in parts) { if (parts.hasOwnProperty(i)) { var part = parts[i]; var $partInput = $('[name=' + fieldData.name + part +']'); var partValue = fieldData.defaultValue[i]; if (obj.hasOwnProperty(fieldData.name)) { partValue = obj[fieldData.name][i]; } if (part === 'A') { // Display as percentage, to match opacity input partValue *= 100; } $partInput.val(partValue); } } } else { console.error('Unexpected field type: ' + fieldData.type); } if (fieldData.hasOwnProperty('isOptional') && fieldData.isOptional) { var $enabledCheckbox = $('[name=' + fieldData.name +'Enabled]'); $enabledCheckbox.prop('checked', isEnabled); } } var loadFromObject = function(obj) { for (var field of fields) { loadFieldFromObject(field, obj); } doRefreshPlus(); } var copyFxDataToClipboard = function() { navigator.permissions.query({name: "clipboard-write"}).then(result => { if (result.state == "granted" || result.state == "prompt") { navigator.clipboard.writeText(JSON.stringify(buildFxData())) .then(function() { window.alert('The FX data has been copied to the clipboard'); }, function() { window.alert('Could not copy to clipboard'); }); } }); } var loadFxDataFromClipboard = function() { navigator.permissions.query({name: "clipboard-read"}).then(result => { if (result.state == "granted" || result.state == "prompt") { navigator.clipboard.readText() .then(function(rawText) { try { var json = JSON.parse(rawText); loadFromObject(json); } catch (e) { console.error(e); window.alert('The contents of the clipboard does not appear to be FX data'); } }, function() { window.alert('Could not read from clipboard'); }); } }); } var init = function() { var css = ` #controls { display: none; } #controlsPlus { position: absolute; z-index: 1000; top: 10px; left: 10px; width: 450px; height: auto; padding: 10px; background-color: rgba(255,255,255,0.5); } #controlsPlus .form-control { padding: .125rem .5rem; } #controlsPlus .form-control:disabled, #controlsPlus .form-control[readonly] { background-color: transparent; border-color: #495057; color: #495057; } #controlsPlus .form-check { display: flex; align-items: center; height: calc(1.5em + .75rem + 2px); } #controlsPlus .form-check-input { margin-top: 0; } #controlsPlus .colour-part-r { border-color: #F00; background-color: #FBB; } #controlsPlus .colour-part-g { border-color: #0F0; background-color: #BFB; } #controlsPlus .colour-part-b { border-color: #00F; background-color: #BBF; } `; $(document.head).append($('<style type="text/css">' + css + '</style>')); $(document.head).append($('<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">')); var $controlsPlus = $('<form id="controlsPlus"></div>'); addBuiltInLoader($controlsPlus); $controlsPlus.append($('<hr />')); for (var field of fields) { addFieldToForm($controlsPlus, field); } var importExportHtml = ` <div class="form-row"> <div class="col"> <button type="button" id="importBtn" class="btn btn-block btn-primary">Import from Clipboard</button> </div> <div class="col-auto"> <div class="form-control-plaintext">|</div> </div> <div class="col"> <button type="button" id="exportBtn" class="btn btn-block btn-primary">Export to Clipboard</button> </div> `; $controlsPlus.append($(importExportHtml)); $(document.body).append($controlsPlus); $('#controlsPlus input[type=checkbox]').on('change', function() { var $checkbox = $(this); var linkedFields = $checkbox.data('linked-fields'); for (var linkedField of linkedFields) { $('[name=' + linkedField + ']').prop('disabled', !$checkbox.prop('checked')); } }); $('#controlsPlus input').on('change', function() { doRefreshPlus(); }); $('#controlsPlus button#loadButton').on('click', function() { loadFromObject(getBuiltInValues()); }); $('#controlsPlus button#importBtn').on('click', function() { loadFxDataFromClipboard(); }); $('#controlsPlus button#exportBtn').on('click', function() { copyFxDataToClipboard(); }); } init(); loadFromObject({}); })();