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!
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]]"
});
}
});
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]]"
});
}
});
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]]"
});
}
});
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]]"
});
}
});
- `"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.
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
}
);
}
});
});
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
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
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);
}
}
});
});
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);
}
}
});
});
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);
};
});