This script allows tokens to follow another token at a specified euclidean distance based on the map's scale. A token will follow the exact path as the lead token. A token's "lastmove" is maintained during the auto-follow. Follows can be daisy chained, meaning token A can follow token B at the same time that token B is following token C. Important -- A token moved by auto-follow does not trigger a change event. The script does not use state data. Auto-follows are lost when the script restarts. Commands To start following - select a token and run the first Macro. Should prompt to select a target, and then a follow range. To stop following - select a token and either run the second Macro or move the token manually. Macros: !af @{selected|token_id} @{target|token_id} ?{Follow Range|10}
!af @{selected|token_id} stop Script There are four configurable options at the top of the script. SendAs - the name displayed if the script responds to the chat window MaxFollowRange - the maximum distance a token can follow another token MinFollowRange - the minimum distance a token can follow another token AllowNpcFollow - controls if PCs can autofollow an NPC Notify - sends general chat msgs when a token starts or stops auto-following. If a user enters a value below the min value or above the max value, the script will automatically adjust to keep the value within the limits. AllowNpcFollow uses the Controlled By field of the token. Any assignment in token Controlled By will allow PCs to autofollow. If the token does not have a Controlled By value but is linked to a character sheet, then the character sheet is checked for Controlled By. Again, any assignment here will allow PCs to follow that character's token. GMs can always follow tokens, regardless of this setting. var GAF = {};
//Configurable Options
GAF.SendAs = "GM"; //script whisper name
GAF.MaxFollowRange = 60; // feet
GAF.MinFollowRange = 5; //feet
GAF.AllowNpcFollow = false; //follow tokens with no player control
GAF.Notify = true; //notify players of follows
//do not modify below this line
GAF.Map = [];
GAF.Future = 1;
GAF.Present = 0;
GAF.Past = -1;
function FollowerControl() {
var id = undefined;
var pCurrent = undefined;
var d = 0;
var lastmove = [];
var nextmove = [];
var history = [];
var p0 = {x:0, y:0};
var p1 = {x:0, y:0};
var pixelRatio = 1;
var setMap = function(id) {
//return if map already defined
if(GAF.Map[id]) return;
//set defaults for map
GAF.Map[id] = [];
GAF.Map[id]["history"] = [];
GAF.Map[id]["nextmove"] = [];
GAF.Map[id]["lag"] = 0;
GAF.Map[id]["following"] = undefined;
GAF.Map[id]["leading"] = [];
}
this.initControl = function(token) {
id = token.get("_id");
setMap(id);
p0 = {x: parseFloat(token.get("left")), y: parseFloat(token.get("top"))};
pixelRatio = 70 / getObj("page", token.get("_pageid")).get("scale_number");
};
this.stopFollow = function(notify) {
//get id of my lead
var leadid = GAF.Map[id]["following"];
if(!leadid) return;
//remove myself from lead[leading]
var leading = GAF.Map[leadid]["leading"];
var i = leading.indexOf(id);
if(i != -1) leading.splice(i, 1);
//reset following
GAF.Map[id]["following"] = undefined;
GAF.Map[id]["lag"] = 0;
GAF.Map[id]["nextmove"] = [];
//notify
if(notify && GAF.Notify) {
var token = getObj("graphic", id).get("name");
var lead = getObj("graphic", leadid).get("name");
sendChat(GAF.SendAs, (token == "" ? "Token" : token) + " stopped following " + (lead == "" ? "Token" : lead));
}
};
this.startFollow = function(obj, lag) {
//stop following previous lead, if any
this.stopFollow(false);
//make sure lead is mapped
lid = obj.get("_id");
setMap(lid);
//set following
GAF.Map[id]["lag"] = lag;
GAF.Map[id]["following"] = lid;
//set lead
var leading = GAF.Map[lid]["leading"];
leading.push(id);
GAF.Map[lid]["leading"] = leading;
//notify players
if(GAF.Notify) {
var token = getObj("graphic", id).get("name");
var lead = obj.get("name");
sendChat(GAF.SendAs, (token == "" ? "Token" : token) + " is following " + (lead == "" ? "Token" : lead));
}
};
this.moveFollowers = function() {
var followers = GAF.Map[id]["leading"];
_.each(followers, function(fid) {
autoFollow(fid);
});
};
var autoFollow = function(fid) {
//get the following token
var token = getObj("graphic", fid);
if(!token) return;
d = 0;
p1 = p0;
lastmove = [];
nextmove = [];
history = [];
pCurrent = undefined;
//add lead lastmove
var pts = convertLastMove(getObj("graphic", id).get("lastmove"));
for(var i = pts.length - 1; i >= 0; i -= 1) {
addPoint(fid, pts[i], GAF.Future);
}
//add my nextmove
pts = GAF.Map[fid]["nextmove"];
for(var i = pts.length - 1; i >= 0; i -= 1) {
addPoint(fid, pts[i], GAF.Future);
}
//add my position
addPoint(fid, {x: parseFloat(token.get("left")), y: parseFloat(token.get("top"))}, GAF.Present);
//add my history
pts = GAF.Map[fid]["history"];
for(var i = pts.length - 1; i >= 0; i -= 1) {
addPoint(fid, pts[i], GAF.Past);
}
//record results and set token
GAF.Map[fid]["history"] = history;
GAF.Map[fid]["nextmove"] = nextmove;
token.set({
left: pCurrent.x,
top: pCurrent.y,
lastmove: lastmove.toString(),
});
//recursive for followers
var tc = new FollowerControl()
tc.initControl(token);
tc.moveFollowers();
}
var addPoint = function(tid, p2, typeMove) {
//calculate move from p1 to p2
var dLag = GAF.Map[tid]["lag"] * pixelRatio;
var dtmp = pixelDistance(p1, p2);
//check if current position has been found
if(pCurrent) { //) {
if(typeMove != GAF.Past) {
lastmove.unshift(p2.x, p2.y);
}
if(d + dtmp + dLag <= GAF.MaxFollowRange * pixelRatio) {
//add to history
history.unshift(p2);
}
} else {
if(typeMove == GAF.Present || d + dtmp >= dLag) {
if(d + dtmp == dLag) {
pCurrent = p2;
} else {
//add to last move and history
lastmove.unshift(p2.x, p2.y);
history.unshift(p2);
//calculate the current position between p1 and p2
p2 = {x: p2.x - p1.x, y: p2.y - p1.y};
var dRemain = pixelDistance(p2, {x: 0, y: 0});
var dRatio = Math.min(1, (dLag - d) / dRemain);
pCurrent = {x: p1.x + p2.x * dRatio, y: p1.y + p2.y * dRatio};
}
} else {
//add to nextmove
nextmove.unshift(p2);
}
}
//set for next iteration
d += dtmp;
p1 = p2;
}
this.appendHistory = function() {
//get previous history
var history = GAF.Map[id]["history"];
//add last move to history
var lastmove = getObj("graphic", id).get("lastmove");
history = history.concat(convertLastMove(lastmove));
//limit history to GAF.MaxFollowRange
var d = 0;
var p1 = p0;
var i = -1;
for(i = history.length - 1; i >= 0; i -= 1) {
var p2 = {x: history[i].x, y: history[i].y};
d += pixelDistance(p1, p2);
if(d >= GAF.MaxFollowRange * pixelRatio) break;
p1 = p2;
}
//record history
if(i >= 0) history.splice(0, i);
GAF.Map[id]["history"] = history;
}
var pixelDistance = function(point1, point2) {
return Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2));
}
var convertLastMove = function(lastmove) {
var pts = [];
lastmove = lastmove.split(",");
for(var i = 0; i < lastmove.length; i += 2) {
pts.push({x: parseFloat(lastmove[i]), y: parseFloat(lastmove[i+1])});
}
return pts;
}
};
on("chat:message", function(msg) {
//respond to correct commands only
var cmd = "!af";
var param = msg.content.split(" ");
if(msg.type != "api" || param[0].toLowerCase() != cmd) return;
//verify param[1] is a valid token
var selected = getObj("graphic", param[1]);
if (selected == undefined) {
sendChat(GAF.SendAs, "/w " + msg.who.split(" ")[0] + " Selected Token ID is invalid.");
return;
}
//verify param[2] is a stop command
if(param[2].toLowerCase() == "stop") {
var tc = new FollowerControl();
tc.initControl(selected);
tc.stopFollow(true);
return;
}
//verify param[2] is a valid token
var targeted = getObj("graphic", param[2]);
if (targeted == undefined) {
sendChat(GAF.SendAs, "/w " + msg.who.split(" ")[0] + " Target Token ID is invalid.");
return;
}
//if set, prevent following of non-player controlled tokens unless GM
//any assignment in ControlledBy will allow autofollow
if(msg.who.indexOf(" (GM)") == -1 && !GAF.AllowNpcFollow) {
var allowedTarget = true;
//check if token has an assignment
if(targeted.get("controlledby") == "") {
//if token has no assignment then check character sheet, if linked
var char = getObj("character", targeted.get("represents"));
if(!char) {
//prevent follow if no character sheet
allowedTarget = false;
} else {
//prevent follow if character sheet has no assignment
if(char.get("controlledby") == "") allowedTarget = false;
}
}
//prevent autofollow if neither token nor character has an assignment
if(!allowedTarget) {
sendChat(GAF.SendAs, "/w " + msg.who.split(" ")[0] + " You are not allowed to follow that target.");
return;
}
}
//verify param[3] is numeric
var distance = param[3];
if (isNaN(distance)) {
sendChat(GAF.SendAs, "/w " + msg.who.split(" ")[0] + " Follow Range should be numeric.");
return;
}
//keep distance within limits
distance = Math.min(GAF.MaxFollowRange, Math.max(distance, GAF.MinFollowRange));
//initialize follow
var tc = new FollowerControl();
tc.initControl(selected);
tc.startFollow(targeted, distance);
});
on("change:graphic", function(obj, prev) {
//check if token moved
if(obj.get("_subtype") != "token" || (obj.get("top") == prev["top"] && obj.get("left") == prev["left"])) return;
//initialize contorl
var tc = new FollowerControl();
tc.initControl(obj);
//stop auto-follow because token was moved manually
tc.stopFollow(true);
//record token's lastmove into history
tc.appendHistory();
//move the followers
tc.moveFollowers();
});