As part of Jumpgate, Roll20’s Improved Tabletop Experience, we introduced a new Babylon graphics engine, that results in a new representation for how line objects are drawn using paths on the Tabletop. Jumpgate objects now load much faster, can be scaled without altering line thickness, and are simpler to work with for Mod authors. In the production and experimental APIs, we recently introduced a new object type of “pathv2” to represent objects drawn in Jumpgate. Introducing this new object type allows Mod (API) Scripts to interact with the lines being drawn by players in the Jumpgate UI, while allowing existing scripts (like UniversalVTTImporter) to continue to function without receiving unexpected data types. Mod (API) Scripts that want to take advantage of the full power of Jumpgate's new line representation (like DryErase and ItsATrap) will need to be adjusted to recognize the pathv2 object format. A couple of the Mod Scripts (PathMath, Token Collisions, Its a Trap) have already been updated to support pathv2 on Jumpgate, and more are in the pipeline. We would appreciate our community’s support in updating other Mod Scripts so everyone can continue to use them to facilitate great games! Before we get too into the weeds, we're linking the updated API documentation here . Example code 1) Rectagle [`"rec"`] This draws a simple, blue square using the `rec` shape. on('ready',()=>{ let page = getObj('page',Campaign().get('playerpageid')); if(page) { createObj('pathv2',{ layer: "objects", pageid: page.id, shape: "rec", stroke: '#0000ff', stroke_width: 3, fill: '#ddddff', x: 105, y: 105, points: "[[0,0],[70,70]]" }); } }); 2) Ellipse [`"eli"`] This draws a simple, magenta circle using the `eli` shape. on('ready',()=>{ let page = getObj('page',Campaign().get('playerpageid')); if(page) { createObj('pathv2',{ layer: "objects", pageid: page.id, shape: "eli", stroke: '#ff00ff', stroke_width: 3, fill: '#ffddff', x: 175, y: 175, points: "[[0,0],[70,70]]" }); } }); 3) Polyline [`"pol"`] This draws the triangle example from above in red using the `pol` shape. on('ready',()=>{ let page = getObj('page',Campaign().get('playerpageid')); if(page) { createObj('pathv2',{ layer: "objects", pageid: page.id, shape: "pol", stroke: '#ff0000', stroke_width: 3, fill: '#ffdddd', x: 245, y: 105, points: "[[0,0],[0,70],[70,0],[0,0]]" }); } }); 4) Freehand [`"free"`] This draws a teardrop shape in green using the `free` shape. Note the double end points that create a sharp corner end. on('ready',()=>{ let page = getObj('page',Campaign().get('playerpageid')); if(page) { createObj('pathv2',{ layer: "objects", pageid: page.id, shape: "free", stroke: '#00ff00', stroke_width: 3, fill: '#ddffdd', x: 315, y: 175, points: "[[0,0],[35,15],[0,70],[70,70],[70,0],[0,0],[0,0]]" }); } }); PathV2 differences from Legacy Path Objects 1) `_type` The obvious difference is PathV2 objects have a `_type` value of `pathv2`, whereas Legacy Path objects have a type of `path`. This difference is important because it allows scripts to deal with the different objects differently, rather than mixing determination code in everywhere paths are used. The events you register for with your script will be different: `"add:pathv2"` - Event fires for each PathV2 object a user adds. `"change:pathv2"` and `"change:pathv2:<property>"` - Event fires when a user changes a PathV2 object. Registering for individual property changes will be notified for those specific property changes. `"destroy:pathv2"` - Event fires when a PathV2 object is removed. 2) `shape` and `points` PathV2 objects determine how they are drawn based on the value of the `shape` property, then interpret the `points` property based on that `shape`. This separation means the `points` property can be a very simple array of points. Legacy Path objects are SVG drawing specifications and used the `_path` property to determine how to draw them. For Legacy Path objects, the `_path` property was mostly an array of triplets consisting of an operation and the x,y coordinate where the operation should be performed. 3) `x` and `y` PathV2 objects have their center point on the page represented by an `x` and `y` point. The origin is at the top left, and positive values move to the right and down. Legacy Path objects used `left` and `top`. The values are exactly the same, this is just a name change. 4) No `scaleX` or `scaleY` PathV2 objects do not have the scale properties `scaleX` and `scaleY`. Legacy Path objects could be stretched using these properties. To change the scale of a PathV2 object, you simply change the individual points. 5) No `width` or `height` PathV2 objects do not have the bounds properties `width` and `height`. Legacy Path objects specified their bounds manually and drew their lines within those bounds. The bounds of a PathV2 object are calculated internally when they are drawn. If you need to know them in a script you must calculate them from the `points`. Converting scripts from Legacy Path to PathV2 Objects There are two main places where most scripts need to be updated: Creation and Events 1) Creation If you are creating Legacy Paths, there are a few changes to move to creating PathV2 objects. Lets take a simple script that draws the path a player controlled token moved. Original Legacy Path Version When the `lastmove` property changes, this script converts it into an array of SVG line drawing instructions, then creates a Legacy Path from it in the color of the controlling player. on('ready',()=>{ // find the player to use for drawing color const getPrimaryPlayer = (obj) => [getObj('character',obj.get('represents')),obj] .filter(o=>o) .map(o=>o.get('controlledby') .split(/,/) .filter(s=>s.length)[0] )[0]; // convert the lastmove's list of numbers into an array SVG instructions const pointsFromLastMove = (o) => [...(o.get('lastmove').split(/,/)),o.get('left'),o.get('top')] .reduce((m,n,i)=>(m[(i/2|0)]=(m[(i/2|0)]?[...m[(i/2|0)],n]:[i?'L':'M',n]),m),[]); // find the bounds, center, and normalize the points to be relative to the bounds. const normalizedPointsAndCenter = (pts) => { const {mX,mY,MX,MY} = pts.reduce((m,pt)=>({ mX:Math.min(pt[1],m.mX), mY:Math.min(pt[2],m.mY), MX:Math.max(pt[1],m.MX), MY:Math.max(pt[2],m.MY) }),{mX:Number.MAX_SAFE_INTEGER,mY:Number.MAX_SAFE_INTEGER,MX:0,MY:0}); const [cX,cY] = [mX+(MX-mX)/2,mY+(MY-mY)/2]; return {cX,cY,w:MX-mX,h:MY-mY, pts:pts.map(pt=>[pt[0],pt[1]-mX,pt[2]-mY])}; }; on('change:token:lastmove',(obj)=>{ let pid = getPrimaryPlayer(obj); let player = getObj('player',pid); if(player){ let {cX,cY,w,h,pts} = normalizedPointsAndCenter( pointsFromLastMove(obj) ); createObj('path', { pageid: obj.get('pageid'), layer: 'map', path: JSON.stringify(pts), stroke: player.get('color'), stroke_width: 15, fill: 'transparent', left: cX, top: cY, width: w, height: h, scaleX: 1, scaleY: 1, controlledby: pid } ); } }); }); PathV2 Version This version creates PathV2 objects. The points become simpler, not requiring SVG instructions, and we no longer need width and height from the data. on('ready',()=>{
// find the player to use for drawing color
const getPrimaryPlayer = (obj) =>
[getObj('character',obj.get('represents')),obj]
.filter(o=>o)
.map(o=>o.get('controlledby')
.split(/,/)
.filter(s=>s.length)[0]
)[0];
// convert the lastmove's list of numbers into an array points
const pointsFromLastMove = (o) =>
[...(o.get('lastmove').split(/,/)),o.get('left'),o.get('top')]
.reduce((m,n,i)=>(m[(i/2|0)]=(m[(i/2|0)]?[...m[(i/2|0)],n]:[n]),m),[]);
// find the center and normalize the points
const normalizedPointsAndCenter = (pts) => {
const {mX,mY,MX,MY} = pts.reduce((m,pt)=>({
mX:Math.min(pt[0],m.mX),
mY:Math.min(pt[1],m.mY),
MX:Math.max(pt[0],m.MX),
MY:Math.max(pt[1],m.MY)
}),{mX:Number.MAX_SAFE_INTEGER,mY:Number.MAX_SAFE_INTEGER,MX:0,MY:0});
const [cX,cY] = [mX+(MX-mX)/2,mY+(MY-mY)/2];
return {cX,cY,pts:pts.map(pt=>[pt[0]-mX,pt[1]-mY])};
};
on('change:token:lastmove',(obj)=>{
let pid = getPrimaryPlayer(obj);
let player = getObj('player',pid);
if(player){
let {cX,cY,pts} = normalizedPointsAndCenter( pointsFromLastMove(obj) );
createObj('pathv2',
{
pageid: obj.get('pageid'),
layer: 'map',
shape: 'pol',
points: JSON.stringify(pts),
stroke: player.get('color'),
stroke_width: 15,
fill: 'transparent',
x: cX,
y: cY,
controlledby: pid
}
);
}
});
}); Dual Mode Script supporting both PathV2 and Legacy Path objects Many existing scripts need to support their users on both Legacy and Jumpgate. We can check if we are on Jumpgate by looking at the `release` property of the Campaign object. If it's `"jumpgate"`, we can use PathV2 objects. To allow the rest of the code to be agnostic to the platform, the `pathMaker()` function is defined with a version for Jumpgate building PathV2 objects, and a version that builds Legacy Path objects. We then call the function and pass it the data it needs to build the path of whatever type suits the platform. on('ready',()=>{
// Determine if we are on Jumpgate by checking the `release` property on the Campaign() object.
// If we are, return a function that will make `pathv2` objects.
// If we aren't, return one that creates `path` objects.
const pathMaker = (['jumpgate'].includes(Campaign().get('release')))
? (cX,cY,w,h,pts,opts) => createObj('pathv2',{
shape: 'pol',
points: JSON.stringify(pts),
x: cX,
y: cY,
...opts
})
: (cX,cY,w,h,pts,opts) => createObj('path',{
// for path objects, inject the SVG instructions into the points
path: JSON.stringify(pts.map((pt,i)=>[i/2?'L':'M',pt[0],pt[1]])),
left: cX,
top: cY,
width: w,
height: h,
scaleX: 1,
scaleY: 1,
...opts
});
// find the player to use for drawing color
const getPrimaryPlayer = (obj) =>
[getObj('character',obj.get('represents')),obj]
.filter(o=>o)
.map(o=>o.get('controlledby')
.split(/,/)
.filter(s=>s.length)[0]
)[0];
// convert the lastmove's list of numbers into an array points
const pointsFromLastMove = (o) =>
[...(o.get('lastmove').split(/,/)),o.get('left'),o.get('top')]
.reduce((m,n,i)=>(m[(i/2|0)]=(m[(i/2|0)]?[...m[(i/2|0)],n]:[n]),m),[]);
// find the bounds, center, and normalize the points to be relative to the bounds.
const normalizedPointsAndCenter = (pts) => {
const {mX,mY,MX,MY} = pts.reduce((m,pt)=>({
mX:Math.min(pt[0],m.mX),
mY:Math.min(pt[1],m.mY),
MX:Math.max(pt[0],m.MX),
MY:Math.max(pt[1],m.MY)
}),{mX:Number.MAX_SAFE_INTEGER,mY:Number.MAX_SAFE_INTEGER,MX:0,MY:0});
const [cX,cY] = [mX+(MX-mX)/2,mY+(MY-mY)/2];
return {cX,cY,w:MX-mX,h:MY-mY,pts:pts.map(pt=>[pt[0]-mX,pt[1]-mY])};
};
on('change:token:lastmove',(obj)=>{
let pid = getPrimaryPlayer(obj);
let player = getObj('player',pid);
if(player){
let {cX,cY,w,h,pts} = normalizedPointsAndCenter( pointsFromLastMove(obj) );
// use the pathMaker() funciton to create the right kind of path.
pathMaker(cX,cY,w,h,pts,{
pageid: obj.get('pageid'),
layer: 'map',
stroke: player.get('color'),
stroke_width: 15,
fill: 'transparent',
controlledby: pid
}
);
}
});
}); 2) Events If you are migrating from reacting to `path` objects to reacting to `pathv2` objects, it should be as easy as changing the event names and updating the logic to match the new format. Lets take a simple script that toggles the green dot on any tokens under a rectangle drawn by the GM and then deletes the rectangle. Original Legacy Path Version This script responds to the creation of Legacy Path objects using the `add:path` event. After making sure the gm drew this on the objects or gm layer, it checks if it was a rectangle. That needs to be extracted based on the operations in the `path` property, as detailed in the code. Then it gets the bounds for the rectangle and finds tokens who's center point are in the drawn rectangle, toggles their green dot, and then removes the drawn rectangle a second later. on('ready',()=>{ on('add:path',(obj) => { if( playerIsGM(obj.get('controlledby')) // GM drew it && ['objects','gmlayer'].includes(obj.get('layer')) // objects or gm layer ){ // get points let pts = JSON.parse(obj.get('path')); // Is it a rectangle? // // SVG Rectangles will have one of the patterns: // MoveTo, LineTo, LineTo, LineTo, LineTo, Close // MoveTo, LineTo, LineTo, LineTo, LineTo // // MoveTo is represented as 'M' // LineTo is represented as 'L' // Close is represented as 'Z' // // We can walk the list of points and build a string out of the operations // then check if it is either MLLLLZ or MLLLL. if(['MLLLLZ',`MLLLL`].includes(pts.reduce((m,pt)=>`${m}${pt[0]}`,''))){ const halfW = Math.ceil(obj.get('width')/2); const halfH = Math.ceil(obj.get('height')/2); const zone = { minX: obj.get('left')-halfW, maxX: obj.get('left')+halfW, minY: obj.get('top')-halfH, maxY: obj.get('top')+halfH }; // toggle the green dot on each token findObjs({ type:'graphic', subtype:'token', pageid:obj.get('pageid'), layer:obj.get('layer') }) .filter(t=> t.get('left') >= zone.minX && t.get('left') <= zone.maxX && t.get('top') >= zone.minY && t.get('top') <= zone.maxY ) .forEach(t=>t.set('status_green', ! t.get('status_green'))) ; // remove the rectangle in 1 second setTimeout(()=>obj.remove(),1000); } } }); }); PathV2 Version This version uses the `add:pathv2` event to pick up drawn paths. It's much easier to tell if a PathV2 object is a rectangle, just a simple check against the `shape` property. Getting the width and height of a PathV2 rectangle is just slightly more complicated than a Legacy Path object. Since a rectangle will always be two points, with the first `[0,0]`, the second point (at index 1) will be the bounds. The only complication is that if a rectangle was drawn from lower right to upper left, the values will be negative. This is easily handled by taking the absolute value with `Math.abs()`. From here on the script is the same. on('ready',()=>{ on('add:pathv2',(obj) => { if( playerIsGM(obj.get('controlledby')) // GM drew it && ['objects','gmlayer'].includes(obj.get('layer')) // objects or gm layer ){ // Is it a rectangle? if('rec'===obj.get('shape')){ // get points let pts = JSON.parse(obj.get('points')); // rectangle second point sets the bounds (but might be negative) const halfW = Math.ceil(Math.abs(pts[1][0])/2); const halfH = Math.ceil(Math.abs(pts[1][1])/2); const zone = { minX: obj.get('x')-halfW, maxX: obj.get('x')+halfW, minY: obj.get('y')-halfH, maxY: obj.get('y')+halfH }; // toggle the green dot on each token findObjs({ type:'graphic', subtype:'token', pageid:obj.get('pageid'), layer:obj.get('layer') }) .filter(t=> t.get('left') >= zone.minX && t.get('left') <= zone.maxX && t.get('top') >= zone.minY && t.get('top') <= zone.maxY ) .forEach(t=>t.set('status_green', ! t.get('status_green'))) ; // remove the rectangle in 1 second setTimeout(()=>obj.remove(),1000); } } }); }); Dual Mode Script supporting both PathV2 and Legacy Path events. Many existing scripts need to support their users on both Legacy and Jumpgate. To do that, you need to support both types of path objects by subscribing to both events, `add:pathv2` and `add:path`. You can extract the shared functionality and then just call it after some initial processing in each of the event handlers, as shown below. on('ready',()=>{ on('add:pathv2',(obj) => { if( playerIsGM(obj.get('controlledby')) // GM drew it && ['objects','gmlayer'].includes(obj.get('layer')) // objects or gm layer ){ // Is it a rectangle? if('rec'===obj.get('shape')){ // get points let pts = JSON.parse(obj.get('points')); toggleTokensInRect(obj, Math.abs(pts[1][0]), Math.abs(pts[1][1])); } } }); on('add:path',(obj) => { if( playerIsGM(obj.get('controlledby')) // GM drew it && ['objects','gmlayer'].includes(obj.get('layer')) // objects or gm layer ){ // get points let pts = JSON.parse(obj.get('path')); // Is it a rectangle? if(['MLLLLZ',`MLLLL`].includes(pts.reduce((m,pt)=>`${m}${pt[0]}`,''))){ toggleTokensInRect(obj, obj.get('width'), obj.get('height')); } } }); const toggleTokensInRect = (obj,w,h) => { // rectangle second point sets the bounds const halfW = Math.ceil(w/2); const halfH = Math.ceil(h/2); const zone = { minX: obj.get('x')-halfW, maxX: obj.get('x')+halfW, minY: obj.get('y')-halfH, maxY: obj.get('y')+halfH }; // toggle the green dot on each token findObjs({ type:'graphic', subtype:'token', pageid:obj.get('pageid'), layer:obj.get('layer') }) .filter(t=> t.get('left') >= zone.minX && t.get('left') <= zone.maxX && t.get('top') >= zone.minY && t.get('top') <= zone.maxY ) .forEach(t=>t.set('status_green', ! t.get('status_green'))) ; // remove the rectangle in 1 second setTimeout(()=>obj.remove(),1000); }; });