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