// Github: https://github.com/bpunya/roll20-api/blob/master/KABOOM/1.2/KABOOM.js // README: https://github.com/bpunya/roll20-api/blob/master/KABOOM/README.md // Author: PaprikaCC (Bodin Punyaprateep) /* ************************************************************************** */ var KABOOM = KABOOM || (function () { // This script allows GMs to send things flying! // Please read the README.md found in the Roll20-api-scripts repository const version = '1.2'; const lastUpdate = 1490120540; const VFXtypes = { acid: { startColour: [50, 50, 50, 1], startColourRandom: [0, 10, 10, 0.25], endColour: [0, 75, 30, 0], endColourRandom: [0, 20, 20, 0], }, blood: { startColour: [175, 0, 0, 1], startColourRandom: [20, 0, 0, 0], endColour: [175, 0, 0, 0], endColourRandom: [20, 0, 0, 0], }, charm: { startColour: [200, 40, 150, 1], startColourRandom: [25, 5, 20, 0.25], endColour: [200, 40, 150, 0], endColourRandom: [50, 10, 40, 0], }, death: { startColour: [10, 0, 0, 1], startColourRandom: [5, 0, 0, 0.25], endColour: [20, 0, 0, 0], endColourRandom: [10, 0, 0, 0], }, fire: { startColour: [220, 35, 0, 1], startColourRandom: [62, 0, 0, 0.25], endColour: [220, 35, 0, 0], endColourRandom: [60, 60, 60, 0], }, frost: { startColour: [90, 90, 175, 1], startColourRandom: [0, 0, 0, 0.25], endColour: [125, 125, 255, 0], endColourRandom: [0, 0, 0, 0], }, holy: { startColour: [175, 130, 25, 1], startColourRandom: [20, 10, 0, 0.25], endColour: [175, 130, 50, 0], endColourRandom: [20, 20, 20, 0], }, magic: { startColour: [50, 50, 50, 0.5], startColourRandom: [150, 150, 150, 0.25], endColour: [128, 128, 128, 0], endColourRandom: [125, 125, 125, 0], }, slime: { startColour: [0, 250, 50, 1], startColourRandom: [0, 20, 10, 0.25], endColour: [0, 250, 50, 0], endColourRandom: [20, 20, 20, 0], }, smoke: { startColour: [150, 150, 150, 1], startColourRandom: [10, 10, 10, 0.5], endColour: [200, 200, 200, 0], endColourRandom: [10, 10, 10, 0], }, water: { startColour: [15, 15, 150, 1], startColourRandom: [5, 5, 25, 0.25], endColour: [10, 10, 100, 0], endColourRandom: [10, 10, 25, 0], }, }; const Layers = ['objects', 'map']; const Chat_Formatting_START = '
' + '
'; const Chat_Formatting_END = '
' + '
'; // checkVersion and checkGlobalConfig are run on startup const checkVersion = function () { if (!state.KABOOM) clearState(); checkGlobalConfig(); log(`-- KABOOM v${version} -- [${new Date(lastUpdate * 1000)}]`); } const checkGlobalConfig = function () { var g = globalconfig.kaboom; if (g && g.lastsaved && g.lastsaved > state.KABOOM.lastupdated) { state.KABOOM.lastupdated = g.lastsaved; state.KABOOM.default_layer = g['Default layer to affect']; state.KABOOM.explosion_ratio = Math.abs(g['Explosion ratio']); state.KABOOM.gm_only = g['GM only'] === 'true'; state.KABOOM.drawings_only = g['Affect drawings only'] === 'true'; state.KABOOM.walls_stop_movement = g['Dynamic Lighting walls stop movement'] === 'true'; } }; const checkCollision = function (pathToMove, walls) { if (!walls) return pathToMove[1]; const intersect = getCollisionPoint(pathToMove, walls); if (intersect) { const obj1 = pathToMove[0]; const obj2 = intersect[0]; const d_x = obj2[0] - obj1[0]; const d_y = obj2[1] - obj1[1]; let distance = Math.sqrt((d_x * d_x) + (d_y * d_y)); if (distance <= 30) return pathToMove[0]; distance -= 30; const theta = Math.atan2(d_y, d_x); const new_d_x = Math.cos(theta) * distance; const new_d_y = Math.sin(theta) * distance; const new_x = obj1[0] + new_d_x; const new_y = obj1[1] + new_d_y; return [new_x, new_y]; } else { return pathToMove[1]; } }; const clearState = function () { state.KABOOM = { 'vfx': true, 'ignore_size': false, 'default_type': 'fire', 'same_layer_only': true, 'min_size': 1, 'max_size': 9, 'scattering': true, 'explosion_ratio': 2, 'default_layer': 'objects', 'gm_only': true, 'drawings_only': false, 'walls_stop_movement': true, 'lastupdated': 0 }; }; // Handles VFX and prepares movement const createExplosion = function (explosion, options, pageInfo) { const scale = pageInfo.scale * Math.abs(options.effectPower) / 70; const sparcity = options.effectRadius / Math.abs(options.effectPower) / state.KABOOM.explosion_ratio; const VFXdata = getExplosionVFX(options.type, scale, sparcity); spawnFxWithDefinition(explosion.position[0], explosion.position[1], VFXdata, explosion.pageid); }; // Returns an array of all valid drawings to move const findGraphics = function (token) { const graphicArray = findObjs({ _type: 'graphic', _pageid: token.pageid, layer: state.KABOOM.same_layer_only ? token.layer : true, }); if (state.KABOOM.drawings_only) return _.filter(graphicArray, graphic => graphic.get('isdrawing')); return graphicArray; }; // Returns an array of all paths on the dynamic lighting layer const findWalls = function (pageid) { wallArray = findObjs({ layer: 'walls', _type: 'path', _pageid: pageid, }); // This is to make the array nice and find out where the points actually are var completePointArray = _.map(wallArray, function (wall) { const pathTuple = JSON.parse(wall.get('path')); const transformInfo = PathMath.getTransformInfo(wall); const pointArray = _.map(pathTuple, (tuple => PathMath.tupleToPoint(tuple, transformInfo))); return pointArray; }) return completePointArray; }; const getCollisionPoint = function (pathToMove, walls) { const intersectArray = []; _.each(walls, function (wall) { for (var a = 0; a < wall.length - 1; a++) { const intersect = PathMath.segmentIntersection(pathToMove, [wall[a], wall[a + 1]]); if (intersect) intersectArray.push(intersect); } }); const closestIntersect = _.chain(intersectArray).sortBy(value => value[1]).first().value() return closestIntersect; }; // Returns an array of the input object's coordinates const getCoordinates = function (obj) { return [obj.get('left'), obj.get('top')] }; // Exposed through KABOOM.getExplosionVFX(@param1, @param2, @param3) // Return an object to use with spawnFxWithDefinition() // @param1 is a all colour data. You can use built in values by passing a string with the name of the value, or an custom VFX object. // @param2 is a radius in units on the tabletop. // @param3 is a sparcity value. Using 1 as a base, higher values reduces the amount of particles created. const getExplosionVFX = function (type, rawRadius, rawSparcity) { const radius = Math.abs(rawRadius) || 2; const sparcity = Math.abs(rawSparcity) || 1; const base = { "maxParticles": 300 / (sparcity + 1) * 2, "emissionRate": 300 / (sparcity + 1) * 2, "duration": 1, "lifeSpan": 15, "lifeSpanRandom": 3.5, "angle": 0, "angleRandom": 360, "size": 17.5 * radius * (sparcity + 1) / 2, "sizeRandom": 5 * radius * (sparcity + 1) / 2, "speed": 5 * radius * sparcity, "speedRandom": radius * sparcity / 2, }; const colour = type.startColour ? type : VFXtypes[type]; return Object.assign(base, colour); }; // Returns a neat object with page properties const getPageInfo = function (pageid) { const page = getObj('page', pageid) if (!page) return undefined return { scale: 70 / page.get('scale_number'), max_x: page.get('width') * 70, max_y: page.get('height') * 70 }; }; const getRandomInt = function (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }; // Returns the 'weight' of the object (to modify distance thrown) from 0 to 1 // If the weight is lower than min_threshold, the returned value is always 1 // If the weight is higher than max_threshold, the returned value is always 0 const getWeight = function (weight, min_threshold, max_threshold) { return min_threshold > max_threshold ? 0 : weight < min_threshold ? 1 : weight > max_threshold ? 0 : -(weight - min_threshold) / (max_threshold - min_threshold) + 1 }; // Handles chat input const handleChatInput = function (msg) { if (msg.type !== 'api' || !playerIsGM(msg.playerid) && state.KABOOM.gm_only) return; const args = msg.content.split(/\s/); switch (args[0].toUpperCase()) { case '!KABOOM': { if (args.length === 1) { showHelp(msg.who); return } const options = parseOptions(args, playerIsGM(msg.playerid)); const graphic = msg.selected ? getObj('graphic', msg.selected[0]._id) : getObj('graphic', _.find(args, id => getObj('graphic', id))); // Error checking! if (!options.effectPower) { return; } else if (options.effectRadius && (options.effectPower > options.effectRadius)) { printToChat(msg.who, 'Effect radius must be higher than the effect power'); return; } else if (!graphic) { printToChat(msg.who, 'Please select one token to designate the center of the explosion.'); return; } prepareExplosion(options, graphic); } } }; // ************************************************************************** // * The important function - moveGraphic handles all distance calculations * // ************************************************************************** const moveGraphic = function (flying_object, explosion_center, options, page, walls) { if (flying_object.id === explosion_center.id) return; // Separate objects from coords const obj1 = explosion_center.position; const obj2 = getCoordinates(flying_object); // Is there a wall in the way? const intersect = state.KABOOM.walls_stop_movement ? getCollisionPoint([[obj1[0], obj1[1], 1], [obj2[0], obj2[1], 1]], walls) : false; if (intersect) return; // ARE OUR COORDS OKAY? if (obj1[0] < 0 || obj1[1] < 0 || obj1[0] > page.max_x || obj1[1] > page.max_y || obj2[0] < 0 || obj2[1] < 0 || obj2[0] > page.max_x || obj2[1] > page.max_y) { log('KABOOM - Coordinate information is out of bounds.'); return; } // Start math calculations let d_x = (obj2[0] - obj1[0]); let d_y = (obj2[1] - obj1[1]); if (d_x === 0 && d_y === 0) { d_x = Math.random() * 2 - 1; d_y = Math.random() * 2 - 1; } const distance = Math.sqrt((d_x*d_x) + (d_y*d_y)); // Calculate new distance const item_weight = getWeight(flying_object.get('width') * flying_object.get('height') / 4900, state.KABOOM.min_size, state.KABOOM.max_size); const distance_weight = getWeight(distance, Math.abs(options.effectPower * page.scale), options.effectRadius * page.scale); const d_distance = options.effectPower * page.scale * distance_weight * (state.KABOOM.ignore_size ? 1 : item_weight) * (options.scatter ? getRandomInt(75, 125) / 100 : 1); if (d_distance === 0) return; // If moving towards a point, don't overshoot it const new_distance = (options.effectPower < 0 && Math.abs(d_distance) > distance) ? 0 : distance + d_distance; // Calculate new location const theta = Math.atan2(d_y, d_x) + (options.scatter ? (getRandomInt(0, 60) - 30) / 360 * Math.PI * distance_weight : 0); const new_d_x = Math.cos(theta) * new_distance; const new_d_y = Math.sin(theta) * new_distance; let new_x = obj1[0] + new_d_x; let new_y = obj1[1] + new_d_y; // Check intersection with walls here const movement_vector = checkCollision([[obj2[0], obj2[1], 1], [new_x, new_y, 1]], walls); new_x = movement_vector[0]; new_y = movement_vector[1]; // QA STUFF HERE new_x = new_x > page.max_x ? page.max_x : new_x < 0 ? 0 : new_x; new_y = new_y > page.max_y ? page.max_y : new_y < 0 ? 0 : new_y; flying_object.set({ left: new_x, top: new_y }); return { 'distance': distance / page.scale, data: flying_object }; } // Returns an object with options parsed from chat messages. // @input should be msg.content // @stateAccess is a boolean that determines if state can be accessed. const parseOptions = function (input, stateAccess) { let options = { effectPower: parseFloat(input[1]).toString() === input[1] ? parseFloat(input[1]) : undefined, effectRadius: parseFloat(input[2]).toString() === input[2] ? parseFloat(input[2]) : undefined }; for (let i = 1; i < input.length; i++) { // This switch is for explosion specific things switch (input[i]) { case 'no-vfx': case 'invisible': case 'invis': options['vfx'] = false; break; case 'no': if (input[i + 1] === 'vfx') options['vfx'] = false; else if (input[i + 1] === 'scatter') options['scatter'] = false; break; case 'no-scatter': options['scatter'] = false; break; case 'scatter': options['scatter'] = true; break; case 'vfx': options['vfx'] = true; break; }; // Settings saved in state are changed below. if (input[i].slice(0, 2) !== '--') continue; if (_.contains(Object.keys(VFXtypes), input[i].slice(2))) options['type'] = input[i].slice(2); // Players should not be allowed past this point. if (!stateAccess) continue; switch (input[i].slice(2)) { case 'drawings-only': if (input[i + 1] === 'on') state.KABOOM.drawings_only = true; else if (input[i + 1] === 'off') state.KABOOM.drawings_only = false; printToChat('gm', `Explosions will now move ${state.KABOOM.drawings_only ? 'only tokens labeled as drawings' : 'all tokens'}.`); break; case 'type': if (_.contains(Object.keys(VFXtypes), input[i + 1])) state.KABOOM.default_type = input[i + 1]; printToChat('gm', `The default explosion type is now ${state.KABOOM.default_type}.`); break; case 'vfx': if (input[i + 1] === 'on') state.KABOOM.vfx = true; else if (input[i + 1] === 'off') state.KABOOM.vfx = false; printToChat('gm', `VFX are now ${state.KABOOM.vfx ? 'enabled' : 'disabled'} on explosions.`); break; case 'same-layer': if (input[i + 1] === 'on') state.KABOOM.same_layer_only = true; else if (input[i + 1] === 'off') state.KABOOM.same_layer_only = false; printToChat('gm', `Objects ${state.KABOOM.same_layer_only ? 'must be' : "don't have to be"} on the same layer as the explosion token now.`); settingsUnchanged = false; break; case 'scattering': if (input[i + 1] === 'on') state.KABOOM.scattering = true; else if (input[i + 1] === 'off') state.KABOOM.scattering = false; printToChat('gm', `By default, scattering is ${state.KABOOM.scattering ? 'active' : 'inactive'}.`); break; case 'ignore-size': if (input[i + 1] === 'on') state.KABOOM.ignore_size = true; else if (input[i + 1] === 'off') state.KABOOM.ignore_size = false; printToChat('gm', `An object's size is now ${state.KABOOM.ignore_size ? 'ignored' : 'included'} in distance calculations.`); break; case 'min-size': if (parseFloat(input[i + 1]).toString() === input[i + 1]) state.KABOOM.min_size = parseFloat(input[i + 1]); printToChat('gm', `All objects smaller than ${state.KABOOM.min_size} square(s) are now considered light.`); break; case 'max-size': if (parseFloat(input[i + 1]).toString() === input[i + 1]) state.KABOOM.max_size = parseFloat(input[i + 1]); printToChat('gm', `All objects larger than ${state.KABOOM.max_size} square(s) are now considered too heavy to move.`); break; case 'reset': clearState(); printToChat('gm', `KABOOM has reset its internal state.`); break; case 'walls': if (input[i + 1] === 'on') state.KABOOM.walls_stop_movement = true; else if (input[i + 1] === 'off') state.KABOOM.walls_stop_movement = false; printToChat('gm', `The script now ${state.KABOOM.walls_stop_movement ? 'observes' : 'ignores'} walls when calculating movement.`); break; case 'debug': log(state.KABOOM); break; case 'help': showHelp('gm'); break; } } // End Input Check Loop! return options; }; // Exposed externally through KABOOM.NOW(@param1, @param2) // Returns no value // @param1 must contain the property 'effectPower' // @param2 must contain the property 'position' as an array value const prepareExplosion = function (rawOptions, rawCenter) { // Check if our inputs are valid var options = verifyOptions(rawOptions); var explosion_center = verifyObject(rawCenter); var pageInfo = getPageInfo(explosion_center.pageid); // Error checking for API users if (!options.effectPower) { log('KABOOM - Effect power missing.'); return false; } else if (!explosion_center.position) { log('KABOOM - Explosion center missing.'); return false; } else if (options.effectPower > options.effectRadius) { log('KABOOM - Effect radius must always be higher than effect power.'); return false; } else if (!pageInfo) { log('KABOOM - Pageid supplied does not exist.'); return false; } // findObjs arrays here var affectedObjects = findGraphics(explosion_center); var walls = state.KABOOM.walls_stop_movement ? findWalls(explosion_center.pageid) : false; if (options.vfx) { if (options.effectPower > 0) { createExplosion(explosion_center, options, pageInfo) } else { createExplosion(explosion_center, options, pageInfo) setTimeout(() => { createExplosion(explosion_center, options, pageInfo) }, 150) } } return _.chain(affectedObjects) .map(obj => moveGraphic(obj, explosion_center, options, pageInfo, walls)) .filter(o => o) .sortBy('distance') .value(); }; /*********************** END OF EXPOSED FUNCTION ****************************/ // Help HTML hosted here. var showHelp = function (target) { var content = '
' + '

KABOOM!

' + '

The following is a list of all current settings.

' + '
' + '
' + '
' + '` + '` + '` + '` + '` + '` + '` + '` + '` + '
' + '
' + '
' + '

To use KABOOM as a macro or chat command, follow this format:
' + '!KABOOM 15 30 --vfx on
' + 'or
' + '!KABOOM Power Radius --options on/off

' + '
' + '
' + `

To change one or more of KABOOM's settings, enter !KABOOM ` + `before one or more of the commands listed above, followed by an 'on' or 'off'.` + '

' + '
'; printToChat(target, content); }; // Pre-formatted sendChat function. const printToChat = function (target, content) { sendChat('KABOOM', `/w ${target}
` + Chat_Formatting_START + content + Chat_Formatting_END, null, { noarchive: true }); }; // *************************************************************************** // This function just verifies that our options are formed correctly. It // accepts either a single number, or an object with the 'effectPower' property. // The rest of the properties are not required. const verifyOptions = function (options) { if (parseFloat(options) === options) { return { effectPower: options, effectRadius: Math.abs(options * state.KABOOM.explosion_ratio), type: state.KABOOM.default_type, scatter: state.KABOOM.scattering, vfx: state.KABOOM.vfx, }; } else { return { effectPower: (parseFloat(options.effectPower) === options.effectPower) ? options.effectPower : false, effectRadius: (parseFloat(options.effectRadius) === options.effectRadius) ? Math.abs(options.effectRadius) : Math.abs(options.effectPower * state.KABOOM.explosion_ratio), type: (_.contains(Object.keys(VFXtypes), options.type) || (options.type && options.type.startColour && options.type.startColour.length === 4)) ? options.type : state.KABOOM.default_type, scatter: (typeof options.scatter === 'boolean') ? options.scatter : state.KABOOM.scattering, vfx: (typeof options.vfx === 'boolean') ? options.vfx : state.KABOOM.vfx, }; } }; // *************************************************************************** // We use this function to verify that the object is formatted properly for // our other functions. It returns an object with a coordinate array and // pageid property. It only accepts objects in three forms: // 1. An array of coordinates with form [X,Y] // 2. A Roll20 token object. // 3. An object with a position array and other properties. const verifyObject = function (obj) { if (_.isArray(obj)){ return { position: obj, pageid: Campaign().get('playerpageid'), layer: state.KABOOM.default_layer, id: false, }; } else if (typeof obj.get === 'function') { return { position: getCoordinates(obj), pageid: obj.get('_pageid'), layer: obj.get('layer'), id: obj.id, }; } return { position: _.isArray(obj.position) ? obj.position : false, pageid: obj.pageid ? obj.pageid : Campaign().get('playerpageid'), layer: _.contains(Layers, obj.layer) ? obj.layer : state.KABOOM.default_layer, id: obj.id ? obj.id : false, }; }; const registerEventHandlers = () => { on('chat:message', handleChatInput); }; return { getExplosionVFX: getExplosionVFX, NOW: prepareExplosion, CheckVersion: checkVersion, RegisterEventHandlers: registerEventHandlers, }; }()); on('ready', () => { "use strict"; KABOOM.CheckVersion() KABOOM.RegisterEventHandlers() });