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