Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

[Script Request] Twins, but on same page

I've found a similar request to what I'm looking for here ( Mirror Tokens ), but I'm simply looking for a way to have two tokens on the same page copy each other's movement.  So if TokenA moves up one grid space, TokenB moves up one grid space as well. Etc. for left/right.    Twins  is almost perfect, except that when a twin token moves, the other twin moves to the exact same location, instead of simply copying the movement. I've also looked at Marching Order  but that causes the follower token to move towards the leader, instead of simply copying its movement. The use case is for a map with multiple levels showing at the same time, where a token is visible on more than one level at a time; e.g. a large ship with a main deck that has an open middle area with covered interior areas, and several upper decks over those interior areas.  The middle of the deck is the same location on both maps, so if there is a sailor on the main deck, it should have a token on both portions of the map that have duplicated/mirrored stats, bars, status markers, layer, etc. I would also need to be able to do this with several sets of pairs of tokens. On a previous map I used grouped tokens, but I found that ended up quite clunky (make sure to select a token, then move it... not a single click and move) and not as functional (token stats were not mirrored) as I would like. I've been trying to figure out how to make a similar modification to Mirror Tokens, but I can't get the movement portion to work correctly.   Thanks in advance for any help, suggestions, or other ways to do this!  I've done quite a bit of searching but I haven't found something that does this specifically yet.
1683005843

Edited 1683030575
The Aaron
Roll20 Production Team
API Scripter
I can probably help with that tomorrow. How soon do you need it?
My next game is Saturday, so tomorrow would be awesome to give me a chance to do some setup and testing. Thanks as always!
1683035738
The Aaron
Roll20 Production Team
API Scripter
Ok.  Here's a quick snippet I threw together. Commands: !link  Links the selected tokens together !unlink Removes the selected tokens from a group, removing the whole group if there are fewer than 2 tokens left in the group. Currently, it only operates on location.  If you move one of the linked tokens (manually, or with TokenMod), it will move all the other tokens by the same relative amount.  It will not move any tokens off the map, and will revert some portion of the source moving token to maintain the same offset (basically, the moved token will bounce back if it would have pushed a token off the edge). Give this a try and let me know if it meets your needs.  It will be trivial to add additional duplicated properties if that's something you want/need.  This script is only doing movement, whereas Twins does all sorts of other things. Code: /* global TokenMod */ on('ready',()=>{ const scriptName = 'LinkedMovement'; const version = '0.1.0'; const schemaVersion = 0.1; const lastUpdate = 1683035490; const checkInstall = () => { log(`-=> ${scriptName} v${version} <=- [${new Date(lastUpdate*1000)}]`); if ( !state.hasOwnProperty(scriptName) || state[scriptName].version !== schemaVersion ) { log(` > Updating Schema to v${schemaVersion} <`); switch (state[scriptName] && state[scriptName].version) { case 0.1: /* break; // intentional dropthrough */ /* falls through */ case "UpdateSchemaVersion": state[scriptName].version = schemaVersion; break; default: state[scriptName] = { version: schemaVersion, options: {}, group: {}, lookup: {}, nextIndex: 0 }; break; } } }; const nextIndex = () => ++(state[scriptName].nextIndex); const groupTokens = (tokens) => { removeTokens(tokens); const S = state[scriptName]; let idx = nextIndex(); S.group[idx] = tokens.map(t=>t.id); S.group[idx].forEach(id=>S.lookup[id]=idx); }; const removeTokens = (tokens) => { const S = state[scriptName]; tokens.forEach(t=>{ if(Object.prototype.hasOwnProperty.call(S.lookup,t.id)){ S.group[S.lookup[t.id]] = S.group[S.lookup[t.id]].filter(id => t.id !== id); if(1 === S.group[S.lookup[t.id]].length) { delete S.lookup[S.group[S.lookup[t.id]][0]]; delete S.group[S.lookup[t.id]]; } delete S.lookup[t.id]; } }); }; const validDelta2 = (pos,size,bounds,delta) => { let npos = { left: Math.max(size.hWidth, Math.min(bounds.width-size.hWidth,pos.left+delta.left)), top: Math.max(size.hHeight, Math.min(bounds.width-size.hHeight,pos.top+delta.top)) }; return { left: npos.left-pos.left, top: npos.top-pos.top }; }; const validDelta = (obj,bounds,delta) => { let pos = { left: obj.get('left'), top: obj.get('top') }; let hWidth = obj.get('width')/2; let hHeight = obj.get('height')/2; return validDelta2(pos,{hWidth,hHeight},bounds,delta); }; const isValidChange = (obj,prev) => { return ( obj.get('left') !== prev.left || obj.get('top') !== prev.top ); }; const handleChangeGraphic = (obj,prev) => { const S = state[scriptName]; if(Object.prototype.hasOwnProperty.call(S.lookup,obj.id) && isValidChange(obj,prev) ){ let page = getObj('page',obj.get('pageid')); let bounds = { width: page.get('width')*70, height: page.get('height')*70 }; let delta = { left: obj.get('left')-prev.left, top: obj.get('top')-prev.top }; // bound source movement let vDelta = validDelta2({ left:prev.left, top:prev.top },{ hWidth: obj.get('width')/2, hHeight: obj.get('height')/2 }, bounds, delta ); let tokens = S.group[S.lookup[obj.id]].filter(id => obj.id !== id).map(id=>getObj('graphic',id)); vDelta = tokens.reduce((m,t)=>validDelta(t,bounds,m),vDelta); tokens.forEach(t=>t.set({ left:t.get('left')+vDelta.left, top:t.get('top')+vDelta.top })); obj.set({ left:prev.left+vDelta.left, top:prev.top+vDelta.top }); } }; const handleDestroyGraphic = (obj) => { removeTokens([obj]); }; on('chat:message',msg=>{ if('api'===msg.type && /^!(un)?link(\b\s|$)/i.test(msg.content) && playerIsGM(msg.playerid)){ const who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); let tokens = (msg.selected || []) .map(o=>getObj('graphic',o._id)) .filter(g=>undefined !== g) ; let unlink = /^!un/i.test(msg.content); if(unlink) { if(tokens.length>0){ removeTokens(tokens); sendChat('LinkedMovement',`/w "${who}" <code>Unlinked ${tokens.length} token(s).</code>`); } else { sendChat('LinkedMovement',`/w "${who}" <code>Select at least one token to remove from Linked Movement.</code>`); } } else { if(tokens.length>1) { groupTokens(tokens); sendChat('LinkedMovement',`/w "${who}" <code>Linked ${tokens.length} tokens.</code>`); } else { sendChat('LinkedMovement',`/w "${who}" <code>Select at least two tokens to Link their Movement.</code>`); } } } }); const registerEventHandlers = () =>{ on('change:graphic',handleChangeGraphic); on('destroy:graphic',handleDestroyGraphic); if('undefined' !== typeof TokenMod && TokenMod.ObserveTokenChange){ TokenMod.ObserveTokenChange(handleChangeGraphic); } }; checkInstall(); registerEventHandlers(); });
Quick test and it looks like it works great for movement! If it's trivial to add other token properties, then that would be great.  The primary things for my use case would be duplicating bar settings (for tracking hp changes), and the token name (currently I'm using TokenNameNumber, so I have to manually rename the duplicates to be the same name; if this script kept their name the same that would be awesome). But again for my use case if they were treated as basically being the exact same token just in different locations that's how I'm going to be using them - so if I need to move them to the GM layer because they're hidden, etc. that would just be icing on the cake!
1683039127
The Aaron
Roll20 Production Team
API Scripter
Ok, I can add those things this evening, if that works for you.  My workday is starting, so I need to focus on that. =D  I'll go ahead and add all the things that make sense, and also add a command to force a sync, in case you make changes with a script that don't get propagated. TokenNameNumber I'll have to look at to be sure I'm playing nicely with it. =D
Sweet, thank you so much!  I've gotta head into some meetings also. :) I don't typically use TokenNameNumber to renumber  tokens; I'm using the %%NUMBERED%% functionality on the default token to have the tokens automatically numbered when they are pulled out of the Journal or copied from another token, which would happen before the !link syncing in my case.  
1683082170
The Aaron
Roll20 Production Team
API Scripter
Ok, here's an updated version.  It will now sync everything that Twins does.  It still does the relative movement, but will now propagate all other changes, such as the bar values, rotation, layer, light settings, etc.  There's one new command: !sync-link Forces a sync of all existing groups. From here on, when a group is added, it will force a sync of all properties between all the linked tokens.  The oldest token (the one with the lowest id) will be treated as the primary token for this purpose, which means if you have Goblin 6, Goblin 12, Goblin 23, Goblin 74, all will have Goblin 6 for a name and the bar values, aura, etc that it does. Let me know if you have any issues with it. Code: /* global TokenMod */ on('ready',()=>{ const scriptName = 'LinkedMovement'; const version = '0.1.1'; const schemaVersion = 0.1; const lastUpdate = 1683081877; const dupProps = [ 'name', 'width', 'height', 'rotation', 'flipv', 'fliph', // Bar settings (except max & link fields) 'bar1_value', 'bar2_value', 'bar3_value', 'tint_color', 'lastmove', 'controlledby', 'represents', //LDL settings 'light_hassight', 'light_radius', 'light_dimradius', 'light_angle', 'light_losangle','light_multiplier', 'adv_fow_view_distance', //UDL settings // Vision "has_bright_light_vision", "has_limit_field_of_vision", "limit_field_of_vision_center", "limit_field_of_vision_total", "light_sensitivity_multiplier", // Bright Light "emits_bright_light", "bright_light_distance", "has_directional_bright_light", "directional_bright_light_total", "directional_bright_light_center", // Dim Light "emits_low_light", "low_light_distance", "has_directional_dim_light", "directional_dim_light_total", "directional_dim_light_center", "dim_light_opacity", // Night Vision "has_night_vision", "night_vision_tint", "night_vision_distance", "night_vision_effect", "has_limit_field_of_night_vision", "limit_field_of_night_vision_center", "limit_field_of_night_vision_total", // Bar settings (max & link fields) 'bar1_max', 'bar2_max', 'bar3_max', 'bar1_link','bar2_link','bar3_link', "bar_location", "compact_bar", 'layer', 'isdrawing', 'aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square', 'tint_color', 'statusmarkers', 'showplayers_name', 'showplayers_bar1', 'showplayers_bar2', 'showplayers_bar3', 'showplayers_aura1', 'showplayers_aura2', 'playersedit_name', 'playersedit_bar1', 'playersedit_bar2', 'playersedit_bar3', 'playersedit_aura1', 'playersedit_aura2', 'lastmove' ]; const checkInstall = () => { log(`-=> ${scriptName} v${version} <=- [${new Date(lastUpdate*1000)}]`); if ( !state.hasOwnProperty(scriptName) || state[scriptName].version !== schemaVersion ) { log(` > Updating Schema to v${schemaVersion} <`); switch (state[scriptName] && state[scriptName].version) { case 0.1: /* break; // intentional dropthrough */ /* falls through */ case "UpdateSchemaVersion": state[scriptName].version = schemaVersion; break; default: state[scriptName] = { version: schemaVersion, options: {}, group: {}, lookup: {}, nextIndex: 0 }; break; } } }; const nextIndex = () => ++(state[scriptName].nextIndex); const groupTokens = (tokens) => { removeTokens(tokens); const S = state[scriptName]; let idx = nextIndex(); S.group[idx] = tokens.map(t=>t.id).sort(); S.group[idx].forEach(id=>S.lookup[id]=idx); let prime = S.group[idx][0]; let o = tokens.find(t=>prime===t.id); handleChangeGraphic(o,{left:o.get('left'),top:o.get('top')}); }; const forceAllUpdate = (who) => { const S = state[scriptName]; let ids = Object.keys(S.group); let n = ids.length; let c = 0; const burndown = ()=> { let id = ids.shift(); if(id){ c+=S.group[id].length; let o = getObj('graphic',S.group[id][0]); if(o){ handleChangeGraphic(o,{left:o.get('left'),top:o.get('top')}); } setTimeout(burndown,0); } else { sendChat('LinkedMovement',`/w "${who}" <code>Forced sync on ${n} group(s) (${c} token(s)).</code>`); } }; burndown(); }; const removeTokens = (tokens) => { const S = state[scriptName]; tokens.forEach(t=>{ if(Object.prototype.hasOwnProperty.call(S.lookup,t.id)){ S.group[S.lookup[t.id]] = S.group[S.lookup[t.id]].filter(id => t.id !== id); if(1 === S.group[S.lookup[t.id]].length) { delete S.lookup[S.group[S.lookup[t.id]][0]]; delete S.group[S.lookup[t.id]]; } delete S.lookup[t.id]; } }); }; const validDelta2 = (pos,size,bounds,delta) => { let npos = { left: Math.max(size.hWidth, Math.min(bounds.width-size.hWidth,pos.left+delta.left)), top: Math.max(size.hHeight, Math.min(bounds.width-size.hHeight,pos.top+delta.top)) }; return { left: npos.left-pos.left, top: npos.top-pos.top }; }; const validDelta = (obj,bounds,delta) => { let pos = { left: obj.get('left'), top: obj.get('top') }; let hWidth = obj.get('width')/2; let hHeight = obj.get('height')/2; return validDelta2(pos,{hWidth,hHeight},bounds,delta); }; const simpleObj = (o) => JSON.parse(JSON.stringify(o)); const getDupChanges = (obj,prev) => { let o = simpleObj(obj); return dupProps.reduce((m,p)=>{ if(o[p]!==prev[p]){ m[p]=o[p]; } return m; },{}); }; const handleChangeGraphic = (obj,prev) => { const S = state[scriptName]; if(Object.prototype.hasOwnProperty.call(S.lookup,obj.id)){ let page = getObj('page',obj.get('pageid')); let bounds = { width: page.get('width')*70, height: page.get('height')*70 }; let delta = { left: obj.get('left')-prev.left, top: obj.get('top')-prev.top }; // bound source movement let vDelta = validDelta2({ left:prev.left, top:prev.top },{ hWidth: obj.get('width')/2, hHeight: obj.get('height')/2 }, bounds, delta ); let tokens = S.group[S.lookup[obj.id]].filter(id => obj.id !== id).map(id=>getObj('graphic',id)); vDelta = tokens.reduce((m,t)=>validDelta(t,bounds,m),vDelta); let dupChanges = getDupChanges(obj,prev); tokens.forEach(t=>t.set({ left:t.get('left')+vDelta.left, top:t.get('top')+vDelta.top, ...dupChanges })); obj.set({ left:prev.left+vDelta.left, top:prev.top+vDelta.top }); } }; const handleDestroyGraphic = (obj) => { removeTokens([obj]); }; on('chat:message',msg=>{ if('api'===msg.type && /^!(un)?link(\b\s|$)/i.test(msg.content) && playerIsGM(msg.playerid)){ const who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); let tokens = (msg.selected || []) .map(o=>getObj('graphic',o._id)) .filter(g=>undefined !== g) ; let unlink = /^!un/i.test(msg.content); if(unlink) { if(tokens.length>0){ removeTokens(tokens); sendChat('LinkedMovement',`/w "${who}" <code>Unlinked ${tokens.length} token(s).</code>`); } else { sendChat('LinkedMovement',`/w "${who}" <code>Select at least one token to remove from Linked Movement.</code>`); } } else { if(tokens.length>1) { groupTokens(tokens); sendChat('LinkedMovement',`/w "${who}" <code>Linked ${tokens.length} tokens.</code>`); } else { sendChat('LinkedMovement',`/w "${who}" <code>Select at least two tokens to Link their Movement.</code>`); } } } if('api'===msg.type && /^!sync-link(\b\s|$)/i.test(msg.content) && playerIsGM(msg.playerid)){ const who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); sendChat('LinkedMovement',`/w "${who}" <code>Syncing token groups...</code>`); forceAllUpdate(who); } }); const registerEventHandlers = () =>{ on('change:graphic',handleChangeGraphic); on('destroy:graphic',handleDestroyGraphic); if('undefined' !== typeof TokenMod && TokenMod.ObserveTokenChange){ TokenMod.ObserveTokenChange(handleChangeGraphic); } }; checkInstall(); registerEventHandlers(); });
It works beautifully for my first quick test!  And I'll let you know if I have any issues with a live game... though I doubt I will. Your scripts are always so well written. :) 
1683094074
The Aaron
Roll20 Production Team
API Scripter
Cool beans!  =D