// ==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({});
})();