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] Way Point Patrolling

January 30 (11 years ago)

Edited March 18 (9 years ago)
Matt
Pro
UPDATE:  A new version of this script has been posted here.

My first attempt at an API script, thought I'd share, and possibly get some feedback/suggestions on this.

Create a path using any token's last move. For each patrolling token, add a comma-delimited string into the GM Notes section.
Example: patrol,SamplePath,0,0,12,0

GM Note Comma-Delimited String:
  • patrol: always the same
  • pathname: the saved path name
  • direction: determines how the order of points is followed 1=foward, -1 backwards, 0=forward then backward
  • nextpoint: offset point in path, 0 starts at the first waypoint
  • speed: movement rate of the token. speed * 10 = yards moved in 60 seconds
  • rotation: addition rotation of token to face waypoint. -1 = do not face waypoint

Commands
  • !WaypointAdd <pathname> - saves the last move of selected target as <pathname>
  • !WaypointDelete <pathname> - deletes the saved path named <pathname>
  • !WaypointList - displays in chat all saved waypoint path names
  • !TogglePatrol - turns patrolling on and off
  • !ToggleAllPages - sets if patrol should occur on all pages, or just the current campaign page
  • !TogglePaths - sets if the path of tokens should be displayed. if disabled, tokens will move straight to the final destination
  • !ToggleLogs - turns console logging on and off

Debugging when LOGOUTPUT is true.



state.WayPointPatrol = state.WayPointPatrol || {}


var REFRESHRATE = 5 // in seconds
var ENABLEPATROL = true // default action on script load 
var ACTIVEPAGEONLY = true // only patrols token on active page
var SHOWPATHS = true // show the path taken when true
var LOGOUTPUT = false // log calculations in output console


//order of gm notes
var csv = {}
csv.patrol = 0
csv.path = 1
csv.direction = 2
csv.nextpoint = 3
csv.movementrate = 4
csv.rotation = 5


on ("chat:message", function(msg) {
    var cmdName = "!WaypointList";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) {
        
        // list all the saved names in state
        if (Object.keys(state.WayPointPatrol).length == 0) {
            sendChat("","/desc No Path Names in State");
        } else {
            sendChat("","/desc Path Names: " + Object.keys(state.WayPointPatrol).join(","));
        }
    }
}); // !WaypointList


on("chat:message", function(msg) {
    var cmdName = "!WaypointDelete ";


    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) {
        
        //removespaces from path name
        var pathName = msg.content.replace(cmdName,"").split(" ").join("");
        
        //delete the property in state
        delete state.WayPointPatrol[pathName];
        sendChat("","/desc Path [" + pathName + "] deleted.");
        
    }
});  // !WaypointDelete


on("chat:message", function(msg) {
    var cmdName = "!WaypointAdd ";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1 ) {
        
        //verify only one target selected
        try {if (msg.selected.length !== 1) {
                sendChat("","/desc Err: Multiple targets selected")
                return;
            }
        } catch(err) {
            sendChat("","/desc Err: No target selected")
            return;
        }
        
        //remove spaces from waypoint path name and get target token
        var pathName = msg.content.replace(cmdName,"").split(" ").join("");
        var pathToken = getObj("graphic",msg.selected[0]._id);
        
        //exit if there is no last move in token
        if (pathToken.get("lastmove") == "") {
            sendChat("","/desc Err: Selected has no last move")
            return;
        }
        
        //read lastmove into an array
        var pathPoints = pathToken.get("lastmove").split(",");
        
        //add path name to state
        state.WayPointPatrol[pathName] = {};
        
        //save lastmove in state
        state.WayPointPatrol[pathName].WayPoints = [];
        var numOfPoints = pathPoints.length / 2;
        for (var i = 0; i < numOfPoints; i++) {
            state.WayPointPatrol[pathName].WayPoints[i] = {};
            state.WayPointPatrol[pathName].WayPoints[i].Left = parseInt(pathPoints[2*i]);
            state.WayPointPatrol[pathName].WayPoints[i].Top = parseInt(pathPoints[2*i+1]);
        }
        
        //save current position in state
        state.WayPointPatrol[pathName].WayPoints[numOfPoints] = {};
        state.WayPointPatrol[pathName].WayPoints[numOfPoints].Left = parseInt(pathToken.get("left"));
        state.WayPointPatrol[pathName].WayPoints[numOfPoints].Top = parseInt(pathToken.get("top"));
        
        //save the page id for path
        state.WayPointPatrol[pathName].PageID = pathToken.get("_pageid");
        
        //clear the lastmove from the token to keep from adding the same path twice
        pathToken.set("lastmove","");
        
        //send notification of save
        sendChat("","/desc Path [" + pathName +"] added");
        if (LOGOUTPUT) {
            log("Path Added [" + pathName + "]");
            log("- PageID: " + state.WayPointPatrol[pathName].PageID);
            log("- Waypoints: " + state.WayPointPatrol[pathName].WayPoints.length);
        }
    }
});  // !WaypointAdd


on("chat:message", function(msg) {
    var cmdName = "!ToggleLogs";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) {
        
        // enable/disable Console Logging
        LOGOUTPUT = !LOGOUTPUT;
        
        var status = LOGOUTPUT ? "Enabled" : "Disabled";
        sendChat("","/desc Console Logging " + status);
        log("Console Logging " + status);
    }
});  // !ToggleLogs


on("chat:message", function(msg) {
    var cmdName = "!TogglePatrol";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) {
        
        // enable/disable patrol
        ENABLEPATROL = !ENABLEPATROL
        
        var status = ENABLEPATROL ? "Enabled" : "Disabled";
        sendChat("","/desc Waypoint Patrol " + status);
        if (LOGOUTPUT) log("Waypoint Patrol " + status);
    }
});  // !TogglePatrol


on("chat:message", function(msg) {
    var cmdName = "!TogglePaths";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) {
        
        // enable/disable paths
        SHOWPATHS = !SHOWPATHS
        
        var status = SHOWPATHS ? "Shown" : "Hidden";
        sendChat("","/desc Waypoint Paths " + status);
        if (LOGOUTPUT) log("Waypoint Paths " + status);
    }
});  // !TogglePaths


on("chat:message", function(msg) {
    var cmdName = "!ToggleAllPages";
    
    if (msg.type == "api" && msg.content.indexOf(cmdName) !== -1) {
        
        // enable/disable patrol
        ACTIVEPAGEONLY = !ACTIVEPAGEONLY
        
        var status = ACTIVEPAGEONLY ? "Current Page" : "All Pages";
        sendChat("","/desc Patrol Set To " + status);
        if (LOGOUTPUT) log("Patrol Set To " + status);
    }
});  // !ToggleAllPages


on("ready", function() {
    setInterval(function() {
        
        //exit if patrol is turned off
        if (!ENABLEPATROL) return;
        
        //record start time for performance tracking
        if (LOGOUTPUT) {
            log("===BEGIN WAYPOINT PROCESS===");
            var timeStart = +new Date();
        }
        
        //loop for all tokens on object layer
        if (ACTIVEPAGEONLY) {
            var allTokens = findObjs({_type:"graphic", layer:"objects", _pageid:Campaign().get("playerpageid")});
        } else {
            var allTokens = findObjs({_type:"graphic", layer:"objects", });
        }
        _.each(allTokens, function(obj) {
            
            //read the gmnotes from the token, correct for formatting
            var gmn = obj.get("gmnotes").replace(/\%2C/g,",").split(",");
            
            //skip token if not flagged for patrol or token is dead
            try { 
                if (gmn[csv.patrol] != 'patrol' || obj.get("status_dead") != false) return;
            } catch (err) { 
                return; 
            }
            if (LOGOUTPUT) log("MOVING TOKEN: " + obj.get("name"));
            
            //skip if path not for page token is on or if state data doesn't exist
            try{
                var thisPath = state.WayPointPatrol[gmn[csv.path]];
                if (thisPath.PageID !== obj.get("_pageid")) {
                    log("- Err: Path [" + gmn[csv.path] + "].PageID does not match obj.pageid");
                    return;
                }
            } catch (err) { 
                log("- Err: Path [" + gmn[csv.path] + "] not in state"); 
                return; 
            }
            
            if (LOGOUTPUT) log("- Following path ["+ gmn[csv.path] +"]");
            
            //read direction
            try { gmn[csv.direction] = parseInt(gmn[csv.direction]);
            } catch (err) { gmn[csv.direction] = 0; }
            
            //copy path and format based on direction
            thisPath = thisPath.WayPoints.slice(0);
            if ( gmn[csv.direction] == 1 ) {
                //loop waypoints forwards
                //do nothing, waypoints are loaded forward by default
            } else if (gmn[csv.direction] == -1) {
                //loop waypoints backwards
                thisPath.reverse();
            } else {
                //loop waypoints forwards then backwards
                gmn[csv.direction] = 0;
                var thatPath = thisPath.slice(0);
                thatPath.reverse();
                thatPath.pop();
                thatPath.shift();
                thisPath = thisPath.concat(thatPath);
            }
            
            //read next waypoint
            try { 
                gmn[csv.nextpoint] = parseInt(gmn[csv.nextpoint]);
                if (gmn[csv.nextpoint] >= thisPath.length) gmn[csv.nextpoint] = 0;
            } catch (err) { gmn[csv.nextpoint] = 0; }
            
            //read movement rate
            try{ 
                gmn[csv.movementrate] = parseInt(gmn[csv.movementrate]);
                //ADnD 2E: movement rate for human is 12
                if (gmn[csv.movementrate] <= 0) csv.movementrate = 12;  
            } catch (err) { gmn[csv.movementrate] = 12; }
            
            //read rotation
            try { 
                gmn[csv.rotation] = parseInt(gmn[csv.rotation]);
                if (gmn[csv.rotation] < 0) gmn[csv.rotation] = -1;
            } catch (err) { gmn[csv.rotation] = 0; }
            
            //determine the maximum movement in pixels
            //ADnD 2E: movement rate * 10 = the number of yards token can move in 1 round out of combat
            //assumes map distance is measured in feet
            var remainingDistance = gmn[csv.movementrate] * 35 * REFRESHRATE / getObj("page",obj.get("_pageid")).get("scale_number");
            if (LOGOUTPUT) log("- Max Distance: " + remainingDistance + " pixels");
            
            //set referenece position for calculations in distance
            refLeft = parseInt(obj.get("left"));
            refTop = parseInt(obj.get("top"));
            if (LOGOUTPUT) log("- Starting from " + refLeft + "," + refTop);
            
            //set the waypoint movement of token to current location
            obj.set("lastmove", refLeft + "," + refTop);
            
            //move token as long as remainingDistance > 0
            do {
                
                //get the next waypoint and calculate distance
                wayLeft = thisPath[gmn[csv.nextpoint]].Left;
                wayTop = thisPath[gmn[csv.nextpoint]].Top;
                var distanceToWayPoint = Math.sqrt(Math.pow(wayLeft-refLeft,2) + Math.pow(wayTop-refTop,2));
                
                //to prevent an infinite loop
                var loop = 0; 
                
                if (remainingDistance > distanceToWayPoint && loop < 10) {
                    
                    //add waypoint to movement and subtract distance
                    if (SHOWPATHS) obj.set("lastmove", obj.get("lastmove") + "," + wayLeft + "," + wayTop);
                    remainingDistance -= distanceToWayPoint;
                    if (LOGOUTPUT) log("- Next Point: " + wayLeft + "," + wayTop + "  distance: " + distanceToWayPoint.toFixed(1) + "  remaining: " + remainingDistance.toFixed(1));
                    
                    //use waypoint as the next reference point
                    refLeft = parseInt(wayLeft);
                    refTop = parseInt(wayTop);
                    
                    //set for next waypoint, reset as needed
                    gmn[csv.nextpoint] = gmn[csv.nextpoint] + 1;
                    if (gmn[csv.nextpoint] >= thisPath.length) gmn[csv.nextpoint] = 0;
                    
                    obj.set("gmnotes", gmn.toString());
                    
                    //increment loop counter
                    loop += 1;
                    
                } else {
                    
                    //calculate ratio of last movement
                    var moveRatio = remainingDistance / distanceToWayPoint;
                    
                    //set the final position
                    refLeft = parseInt(moveRatio * (wayLeft - refLeft)) + refLeft;
                    refTop = parseInt(moveRatio * (wayTop - refTop)) + refTop;
                    
                    //make sure final position is within the boundries of the map
                    var mapWidth = getObj("page",obj.get("_pageid")).get("width") * 70;
                    var mapHeight = getObj("page",obj.get("_pageid")).get("width") * 70;
                    refLeft = Math.min(Math.max(refLeft,0), mapWidth);
                    refTop = Math.min(Math.max(refTop,0), mapHeight);
                    
                    if (LOGOUTPUT) {
                        log("- Last Point: " + wayLeft + "," + wayTop + "  distance: " + distanceToWayPoint.toFixed(1));
                        log("- Move Ratio: " + (moveRatio*100).toFixed(2) + "% of " + distanceToWayPoint.toFixed(1));
                        log("- Move To: " + refLeft + "," + refTop);
                    }
                    //position token
                    obj.set("left", refLeft);
                    obj.set("top", refTop);
                    if (LOGOUTPUT && SHOWPATHS) log("- lastmove: " + obj.get("lastmove"));
                    
                    //rotate token to face final waypoint
                    if (gmn[csv.rotation] != -1) {
                        var angleRotation = Math.atan2(wayTop - refTop, wayLeft - refLeft) * 180 / Math.PI - 90 + gmn[csv.rotation];
                        obj.set("rotation", angleRotation);
                        if (LOGOUTPUT) log("- rotation: " + angleRotation.toFixed(1));
                    }
                    
                    //force the token to stop moving
                    remainingDistance = 0;
                }
            } while (remainingDistance > 0); 
        })
        if (LOGOUTPUT) {
            var timeProcess = parseInt(+new Date() - timeStart) / 1000;
            log("Waypoint Process Time: " + timeProcess.toFixed(3) + " seconds");
        }
    }, REFRESHRATE * 1000);
});
January 30 (11 years ago)

Edited January 30 (11 years ago)
Matt
Pro
I run mainly ADnD 2E, so I've modified the remainingDistance to be close to real-time movement. Using the script's refresh rate, this will calculate the distance each token is able to move in that amount of time.

var remainingDistance = parseInt(gmn[3]) * 35 * TIMERDELAY / getObj("page",obj.get("_pageid")).get("scale_number");
If I increase my refresh rate from 5 sec to 10 sec, my tokens move less frequent, but cover more distance with each refresh.
February 02 (11 years ago)
Matt
Pro
Reworked the code so the waypoints paths do not have to be hard-coded. You can use the previous move of any token as a new path.
February 02 (11 years ago)
I've never been one for patrolling tokens, but I think it was mainly because I'm very specific as to where I want them to move and not good with coding to get them to do it. That being said, I love this idea and I will likely end up using this. Thanks Matt!
February 03 (11 years ago)

Edited February 03 (11 years ago)
Matt
Pro
Cleaned up the code slightly, and added two features.

ACTIVEPAGEONLY will only move tokens on the current campaign page when set to true. When false, it will move tokens across all pages.

Added direction to the paths. A token can be set to follow a path in order, going back to the first point after reaching the end. It can also be set to follow the path in reverse order. Can also set the token to walk the path forward, then in reverse.
February 03 (11 years ago)
+1