diff --git a/MarchingOrder/2.4/marchingOrder.js b/MarchingOrder/2.4/marchingOrder.js
index 750dc51eba..4e18a0266d 100644
--- a/MarchingOrder/2.4/marchingOrder.js
+++ b/MarchingOrder/2.4/marchingOrder.js
@@ -453,9 +453,10 @@ var MarchingOrder = (() => {
*/
static getState() {
if(!state.marchingOrder)
- state.marchingOrder = {
- defaultOrder: []
- };
+ state.marchingOrder = {};
+ _.defaults(state.marchingOrder, {
+ defaultOrder: []
+ });
return state.marchingOrder;
}
diff --git a/MarchingOrder/3.0/marchingOrder.js b/MarchingOrder/3.0/marchingOrder.js
new file mode 100644
index 0000000000..544245a565
--- /dev/null
+++ b/MarchingOrder/3.0/marchingOrder.js
@@ -0,0 +1,1160 @@
+var MarchingOrder = (() => {
+ 'use strict';
+
+ /**
+ * A line segment consisting of 2 vec3s defining its endpoints.
+ * @typedef {vec3[]} Segment
+ */
+
+ /**
+ * A wrapper around a follower token. This includes positioning information
+ * for that token's place in its marching formation. For simplicity, think
+ * of the du, dv components in this way:
+ * Suppose the leader moves in a long, straight line westward, with the
+ * leader at the origin and the joined segments of their movement proceeding
+ * in the direction of the positive X axis. du would define the follower's
+ * position relative to this X axis, while dv would define the follower's
+ * position relative to the corresponding Y axis.
+ * @typedef {object} FollowerData
+ * @property {Graphic} token The follower token wrapped by this object.
+ * @property {number} du The projected offset of the token parallel to the
+ * leader's movement.
+ * @property {number} dv The projected offset of the token orthogonal
+ * to the leader's movement.
+ */
+
+ // A mapping of cardinal direction names to their unit vectors.
+ const CARDINAL_VECTORS = {
+ 'north': [0, -1, 0],
+ 'south': [0, 1, 0],
+ 'east': [1, 0, 0],
+ 'west': [-1, 0, 0]
+ };
+
+ // The maximum number of segments to track in a leader token's movement.
+ const MAX_SEGMENTS = 20;
+
+ /**
+ * Delete a saved marching formation.
+ * @param {string} name The name of the formation.
+ */
+ function deleteFormation(name) {
+ let moState = MarchingOrder.State.getState();
+ delete moState.savedFormations[name];
+ }
+
+ /**
+ * Get the token persisted in the state. Search first by the token's
+ * character's ID, then by the token's name.
+ * @param {string} id The character ID or token name.
+ * return {Graphic}
+ */
+ function _findPersistedToken(id) {
+ // First try to get the token by its character's ID.
+ let token;
+ let character = findObjs({
+ _type: "character",
+ name: id
+ })[0];
+ if (character) {
+ let tokens = findObjs({
+ _pageid: Campaign().get("playerpageid"),
+ _type: "graphic",
+ represents: character.get('_id')
+ });
+ if (tokens.length === 1)
+ return tokens[0];
+ }
+
+ // Next, try to get the token by its name.
+ if (!token) {
+ let tokens = findObjs({
+ _pageid: Campaign().get("playerpageid"),
+ _type: "graphic",
+ name: id
+ });
+ if (tokens.length === 1)
+ return tokens[0];
+ else if (tokens.length > 1)
+ throw new Error(`Could not find unique token with ID ${id}. ` +
+ `Please make sure there aren't any duplicates on the map.`);
+ }
+ throw new Error(`Token with ID ${id} does not exist.`);
+ }
+
+ /**
+ * Get the token used for persisting a token in a saved formation.
+ * Tokens are persisted by their character name or by their token name
+ * (in that order).
+ * @param {Graphic} token
+ * @return {string}
+ */
+ function _getTokenPersistID(token) {
+ let id = token.get('name');
+ let character = getObj('character', token.get('represents'));
+ if (character)
+ id = character.get('name');
+
+ if (!id)
+ throw new Error(`Token ${token.get('_id')} does not have a valid ` +
+ `persistance identifier (must be a unique character name or ` +
+ `token name)`);
+ return id;
+ }
+
+ /**
+ * Gets the point of a marching offset relative to a line segment,
+ * where the segment's starting point is closer to the front of the
+ * formation and the end point is closer to the rear of the formation.
+ * @param {Segment} segment
+ * @param {number} du The projected offset parallel to the segment.
+ * @param {number} dv The projected offset orthogonal to the segment.
+ * @return {vec3}
+ */
+ function getFollowerOffset(segment, du, dv) {
+ let [p1, p2] = segment;
+ let u = VecMath.sub(p2, p1);
+ let uHat = VecMath.normalize(u);
+ let uLen = VecMath.length(u);
+ let vHat = VecMath.cross([0, 0, 1], uHat);
+
+ let alpha = du/uLen;
+
+ // Find the token's new position projected parallel to the segment.
+ let xy = VecMath.add(p1, VecMath.scale(u, alpha));
+
+ // Then offset it by the token's orthogonal projection.
+ return VecMath.add(xy, VecMath.scale(vHat, dv));
+ }
+
+ /**
+ * Get the token's position as a vector.
+ * @param {Graphic} token
+ * @return {vec3}
+ */
+ function _getTokenPt(token) {
+ let x = token.get('left');
+ let y = token.get('top');
+ return [x, y, 1];
+ }
+
+ /**
+ * Convert a token's lastmove string into a list of line segments, with
+ * the points in order from last to first (end of movement to start of
+ * movement).
+ * @param {Graphic} token The token we're converting the lastmove of.
+ * @return {Segment[]}
+ */
+ function _lastmove2ReverseSegments(token) {
+ let lastmove = token.get('lastmove');
+
+ // Parse the coordinands out of the lastmove string.
+ let coordinands = _.map(lastmove.split(','), x => {
+ return parseInt(x);
+ });
+
+ // Append the token's current position.
+ coordinands.push(parseInt(token.get('left')));
+ coordinands.push(parseInt(token.get('top')));
+
+ // Convert the coordinand pairs into points in homogeneous coordinates, in
+ // reverse order.
+ let points = _.chain(coordinands)
+ .chunk(2)
+ .map(pt => {
+ return [...pt, 1];
+ })
+ .reverse()
+ .value();
+
+ // Convert the points into a list of segments.
+ return _.map(_.range(points.length - 1), i => {
+ let start = points[i];
+ let end = points[i + 1];
+ return [start, end];
+ });
+ }
+
+ /**
+ * Initializes a leader token for a marching formation.
+ * @param {Graphic} leader
+ */
+ function _initLeader(leader) {
+ // Have all the tokens involved leave any formation they were previously in.
+ unfollow(leader);
+ _.each(leader.followers, follower => {
+ unfollow(follower.token);
+ });
+
+ // Reset the leader's marching properties.
+ leader.followers = [];
+ leader.moveSegments = [];
+ }
+
+ /**
+ * Move a leader's followers in formation relative to their
+ * leader's last movement.
+ * @param {Graphic} leader The leader token.
+ */
+ function moveInFormation(leader) {
+ // Convert the leader's lastmove into a list of line segments and prepend
+ // those to an accumulated list.
+ let prevSegments = _.clone(leader.moveSegments);
+ let curSegments = _lastmove2ReverseSegments(leader);
+ leader.moveSegments =
+ _.first([...curSegments, ...prevSegments], MAX_SEGMENTS);
+
+ // Compute the total distance of the leader's tracked movement.
+ let totalMoveDist = _.reduce(leader.moveSegments, (memo, segment) => {
+ let [p1, p2] = segment;
+ return memo + VecMath.dist(p1, p2);
+ }, 0);
+
+ // Have each follower follow in formation with the leader.
+ _.each(leader.followers, follower => {
+ // If the projected distance is farther than the total tracked movement
+ // distance, then just march the follower relative to the last segment.
+ if (follower.data.du >= totalMoveDist) {
+ let lastSeg = _.last(leader.moveSegments);
+ let lastDist = VecMath.dist(lastSeg[0], lastSeg[1]);
+
+ let relDist = (follower.data.du - totalMoveDist) + lastDist;
+
+
+ let xy = getFollowerOffset(lastSeg, relDist, follower.data.dv);
+ _setNewFollowerPosition(leader, follower.token, xy);
+ }
+
+ // Otherwise, find the segment upon which the projected distance would
+ // lie, and march the follower relative to that one.
+ else {
+ let currDist = follower.data.du;
+
+ // Find the segment which the follower's projected distance lies upon.
+ _.find(leader.moveSegments, segment => {
+ let [p1, p2] = segment;
+ let u = VecMath.sub(p2, p1);
+ let uLen = VecMath.length(u);
+
+ if (currDist < uLen) {
+ let xy = getFollowerOffset(segment, currDist, follower.data.dv);
+ _setNewFollowerPosition(leader, follower.token, xy);
+ return true;
+ }
+ else
+ currDist -= uLen;
+ });
+ }
+ });
+ }
+
+ /**
+ * Sets the new position for a token in a formation while also doing
+ * token collision with dynamic lighting walls.
+ * @param {Graphic} leader
+ * @param {Graphic} token
+ * @param {vec3} newXY
+ */
+ function _setNewFollowerPosition(leader, token, newXY) {
+ // Set the new position based on the formation movement.
+ token.set('left', newXY[0]);
+ token.set('top', newXY[1]);
+
+ // Check for collisions with dynamic lighting and update the position if
+ // necessary.
+ token.set('lastmove', leader.get('lastmove'));
+ let walls = findObjs({
+ _type: 'path',
+ _pageid: Campaign().get("playerpageid"),
+ layer: 'walls'
+ });
+ let collision =
+ TokenCollisions.getCollisions(token, walls, { detailed: true })[0];
+ if (collision) {
+ token.set('left', collision.pt[0]);
+ token.set('top', collision.pt[1]);
+ }
+ }
+
+ /**
+ * Create a marching order formation for the given tokens and set them to
+ * follow that formation. Then save it for future use.
+ * @param {string} name The name of the new foramtion.
+ * @param {Graphic} leader The leader of the formation. When the leader
+ * moves, the other tokens will move in that formation.
+ * @param {Graphic[]} followers The tokens that will be following the leader.
+ * @param {string} direction A cardinal direction (north, south, east, or
+ * west) given as the direction the formation is facing at the time it is
+ * created.
+ */
+ function newFormation(name, leader, followers, direction) {
+ let dirVector = CARDINAL_VECTORS[direction.toLowerCase()];
+ if (!dirVector)
+ throw new Error(`${direction} is an invalid cardinal direction.`);
+
+ // Get the vector pointing in the opposition direction from the cardinal
+ // direction.
+ let uHat = VecMath.scale(dirVector, -1);
+
+ // Remove the leader from the list of followers if it was included there.
+ followers = _.reject(followers, follower => {
+ return follower === leader;
+ });
+
+ // Set up the formation data for the leader and its followers.
+ _initLeader(leader);
+ _.each(followers, token => {
+ // Get the vector from the leader to the follower.
+ let tokenXY = _getTokenPt(token);
+ let leaderXY = _getTokenPt(leader);
+ let a = VecMath.sub(tokenXY, leaderXY);
+
+ // Determine the projected distance (du) of the
+ // follower behind the leader.
+ let du = VecMath.scalarProjection(uHat, a);
+
+ // Determine the orthogonal projected distance (dv) of the
+ // follower behind the leader.
+ let vHat = VecMath.cross([0, 0, 1], uHat);
+ let dv = VecMath.scalarProjection(vHat, a);
+
+ _setFormationOnToken(leader, token, {du, dv});
+ });
+
+ // Save the formation for future use.
+ saveFormation(name, leader);
+ MarchingOrder.utils.Chat.broadcast(`Defined new marching formation ` +
+ `${name} with ${leader.get('name')} as the leader.`);
+ }
+
+ /**
+ * Persist a marching formation defined by some leader token and its
+ * followers.
+ * @param {string} name The name of the formation.
+ * @param {Graphic} leader The leader token for the formation.
+ */
+ function saveFormation(name, leader) {
+ let myState = MarchingOrder.State.getState();
+ if (myState.savedFormations[name])
+ throw new Error(`A formation named ${name} already exists!`);
+
+ // Track the leader by either its character's ID, or by its token name
+ // (in that order).
+ let leaderID = _getTokenPersistID(leader);
+ if (!leader.followers)
+ throw new Error(`Could not save the formation. The leader token has ` +
+ `no followers.`);
+
+ // Convert the followers for persistence.
+ let followers = _.map(leader.followers, follower => {
+ // Track the follower by their character ID or by their token name
+ // (in that order).
+ let followerID = _getTokenPersistID(follower.token);
+ let imgsrcRaw = follower.token.get('imgsrc');
+ let imgsrc = MarchingOrder.utils.getCleanImgsrc(imgsrcRaw);
+
+ return {
+ id: followerID,
+ imgsrc,
+ data: follower.data
+ };
+ });
+
+ // Persist the formation under the given name.
+ let leaderImgSrcRaw = leader.get('imgsrc');
+ let leaderImgSrc = MarchingOrder.utils.getCleanImgsrc(leaderImgSrcRaw);
+ myState.savedFormations[name] = {
+ name,
+ leaderID,
+ leaderImgSrc,
+ followers
+ };
+ MarchingOrder.utils.Chat.broadcast(`Saved formation "${name}"!`);
+ }
+
+ /**
+ * Set up a single token to follow its leader with the given formation data.
+ * @param {Graphic} leader The leader token.
+ * @param {Graphic} token The follower token.
+ * @param {object} data An object containing the individual formation data
+ * for token.
+ */
+ function _setFormationOnToken(leader, token, data) {
+ token.leader = leader;
+ leader.followers.push({token, data});
+ }
+
+ /**
+ * The given token leaves any marching order formation it is currently
+ * following.
+ * @param {Graphic} token The token that is leaving its marching formation.
+ */
+ function unfollow(token) {
+ let leader = token.leader;
+ if (leader && leader.followers) {
+ // Remove the token from its leader's list of followers.
+ leader.followers = _.reject(leader.followers, follower => {
+ return follower.token === token;
+ });
+ }
+ }
+
+ /**
+ * Make all tokens leave their marching formation.
+ */
+ function unfollowAll() {
+ let allObjs = findObjs({
+ _type: 'graphic',
+ layer: 'objects'
+ });
+ _.each(allObjs, obj => {
+ obj.leader = undefined;
+ obj.followers = undefined;
+ });
+
+ MarchingOrder.utils.Chat.broadcast(
+ "Ceased all active marching formations.");
+ }
+
+ /**
+ * Load a persisted formation and apply it to its leader and followers.
+ * @param {string} name The name of the formation.
+ */
+ function useFormation(name) {
+ let myState = MarchingOrder.State.getState();
+ let formation = myState.savedFormations[name];
+ if (!formation)
+ throw new Error(`Formation "${name}" doesn't exist.`);
+
+ // Load the token for the leader.
+ let leader = _findPersistedToken(formation.leaderID);
+
+ // Load the leader's followers.
+ _initLeader(leader);
+ _.each(formation.followers, follower => {
+ try {
+ let token = _findPersistedToken(follower.id);
+ _setFormationOnToken(leader, token, follower.data);
+ }
+ catch (err) {
+ // Warn the GM, but skip the token if it isn't available.
+ MarchingOrder.utils.Chat.warn(err);
+ }
+ });
+ MarchingOrder.utils.Chat.broadcast(`Using formation "${name}", ` +
+ `with ${formation.leaderID} as the leader!`);
+ }
+
+ // When the API is loaded, install the Custom Status Marker menu macro
+ // if it isn't already installed.
+ on('ready', () => {
+ try {
+ if(!VecMath)
+ throw new Error("The new version of the Marching Order script " +
+ "requires the Vector Math script. Go install it, or be plagued " +
+ "with errors!");
+ if (!TokenCollisions)
+ throw new Error("The new version of the Marching Order script " +
+ "requires the Token Collisions script. Go install it, " +
+ "or be plagued with errors!");
+
+ let moState = MarchingOrder.State.getState();
+ if (!moState.version)
+ MarchingOrder.utils.Chat.broadcast("Hello, friend! The Marching " +
+ "Order script has undergone a big update since version 3.0. " +
+ "Please check the docs to check out what's new!");
+
+ moState.version = '3.0';
+ MarchingOrder.Macros.installMacros();
+ log('--- Initialized Marching Order v3.0 ---');
+ }
+ catch (err) {
+ MarchingOrder.utils.Chat.error(err);
+ }
+ });
+
+ /**
+ * Set up an event handler to do the marching order effect when the
+ * leader tokens move!
+ */
+ on("change:graphic:lastmove", obj => {
+ try {
+ let token = getObj('graphic', obj.get('_id'));
+
+ // If the token has followers, have them move in formation behind it.
+ if (token.followers)
+ moveInFormation(token);
+
+ // Otherwise, if the token was following in some formation, leave the
+ // formation.
+ else if (token.leader)
+ unfollow(token);
+ }
+ catch(err) {
+ MarchingOrder.utils.Chat.error(err);
+ }
+ });
+
+ return {
+ deleteFormation,
+ getFollowerOffset,
+ moveInFormation,
+ newFormation,
+ saveFormation,
+ unfollow,
+ unfollowAll,
+ useFormation
+ };
+})();
+
+/**
+ * This module defines and implements the chat commands used by this script.
+ */
+(() => {
+ 'use strict';
+
+ const MENU_CMD = '!showMarchingOrderMenu';
+ const NEW_FORMATION_CMD = '!marchingOrderNewFormation';
+ const STOP_ALL_CMD = '!marchingOrderStopAll';
+ const USE_FORMATION_CMD = '!marchingOrderUseFormation';
+ const DELETE_FORMATION_CMD = '!marchingOrderDeleteFormation';
+ const CLEAR_STATE_CMD = '!marchingOrderClearState';
+
+ /**
+ * Process an API command to clear the script's state.
+ */
+ function _cmdClearState(msg) {
+ let player = getObj('player', msg.playerid);
+ if (playerIsGM(msg.playerid)) {
+ let argv = msg.content.split(' ');
+ if (argv.length < 2)
+ throw new Error("CLEAR_STATE_CMD takes 1 parameter: A confirmation " +
+ "for whether you want to delete the script's state.");
+
+ let isSure = (argv[1] === 'yes');
+ if (isSure) {
+ MarchingOrder.State.clearState();
+ MarchingOrder.Wizard.showMainMenu(player);
+ }
+ }
+ else
+ MarchingOrder.utils.Chat.tattle(player, 'Clear State');
+ }
+
+ /**
+ * Delete a saved marching formation.
+ */
+ function _cmdDeleteFormation(msg) {
+ let player = getObj('player', msg.playerid);
+ if (playerIsGM(msg.playerid)) {
+ let argv = msg.content.split(' ');
+ if (argv.length < 2)
+ throw new Error("DELETE_FORMATION_CMD takes 2 parameters: The name " +
+ "for the formation and a confirmation for whether you want to " +
+ "delete the formation.");
+ let formationName = argv.slice(1, -1).join(' ');
+
+ let isSure = (_.last(argv) === 'yes');
+ if (isSure) {
+ MarchingOrder.deleteFormation(formationName);
+ MarchingOrder.Wizard.showMainMenu(player);
+ }
+ }
+ else
+ MarchingOrder.utils.Chat.tattle(player, 'Delete Saved Formation');
+ }
+
+ /**
+ * Process an API command to have tokens follow each other.
+ */
+ function _cmdNewFormation(msg) {
+ let player = getObj('player', msg.playerid);
+ if (playerIsGM(msg.playerid)) {
+ let argv = msg.content.split(' ');
+ if (argv.length < 3)
+ throw new Error("NEW_FORMATION_CMD takes 2 parameters: A cardinal direction and a name for the formation.");
+
+ let followers = MarchingOrder.utils.Chat.getGraphicsFromMsg(msg);
+ let leader = _.find(followers, token => {
+ return token.get('status_black-flag') || token.get('status_flying-flag');
+ });
+ if (!leader)
+ throw new Error(`No leader has been selected. The leader token must ` +
+ `have the black-flag or flying-flag status icon set.`);
+ leader.set('status_black-flag', false);
+ leader.set('status_flying-flag', false);
+
+ let direction = argv[1];
+ let name = argv.slice(2).join(' ');
+ MarchingOrder.newFormation(name, leader, followers, direction);
+ MarchingOrder.Wizard.showMainMenu(player);
+ }
+ else
+ MarchingOrder.utils.tattle(player, 'Delete Saved Formation');
+ }
+
+ /**
+ * Process an API command to use a saved formation.
+ */
+ function _cmdUseFormation(msg) {
+ let player = getObj('player', msg.playerid);
+ if (playerIsGM(msg.playerid)) {
+ let argv = msg.content.split(' ');
+ if (argv.length < 2)
+ throw new Error("NEW_FORMATION_CMD takes 1 parameter: The name for the formation.");
+
+ let name = argv.slice(1).join(' ');
+ MarchingOrder.useFormation(name);
+ }
+ else
+ MarchingOrder.utils.Chat.tattle(player, 'Use Saved Formation');
+ }
+
+ /**
+ * Stops all marching orders currently in use.
+ */
+ function _cmdStopAll(msg) {
+ let player = getObj('player', msg.playerid);
+ if (playerIsGM(msg.playerid))
+ MarchingOrder.unfollowAll();
+ else
+ MarchingOrder.utils.Chat.tattle(player, 'Stop All Formations');
+ }
+
+ /**
+ * Processes an API command to display the script's main menu.
+ */
+ function menu(msg) {
+ let player = getObj('player', msg.playerid);
+ MarchingOrder.Wizard.showMainMenu(player);
+ }
+
+ // Event handler for the script's API chat commands.
+ on('chat:message', msg => {
+ let argv = msg.content.split(' ');
+ try {
+ if(argv[0] === MENU_CMD)
+ menu(msg);
+ else if (argv[0] === NEW_FORMATION_CMD)
+ _cmdNewFormation(msg);
+ else if (argv[0] === STOP_ALL_CMD)
+ _cmdStopAll(msg);
+ else if (argv[0] === USE_FORMATION_CMD)
+ _cmdUseFormation(msg);
+ else if (argv[0] === DELETE_FORMATION_CMD)
+ _cmdDeleteFormation(msg);
+ else if (argv[0] === CLEAR_STATE_CMD)
+ _cmdClearState(msg);
+ }
+ catch(err) {
+ MarchingOrder.utils.Chat.error(err);
+ }
+ });
+
+ /**
+ * Expose the command constants for use in other modules.
+ */
+ MarchingOrder.Commands = {
+ CLEAR_STATE_CMD,
+ DELETE_FORMATION_CMD,
+ MENU_CMD,
+ NEW_FORMATION_CMD,
+ STOP_ALL_CMD,
+ USE_FORMATION_CMD
+ };
+})();
+
+(() => {
+ 'use strict';
+
+ /**
+ * Module for global script configurations.
+ */
+ MarchingOrder.Config = class {
+
+ /**
+ * Get the configured default marching order.
+ */
+ static getDefaultMarchingOrder() {
+ return MarchingOrder.State.getState().defaultOrder;
+ }
+
+ /**
+ * Set the configured default marching order.
+ * @param {Graphic} leader
+ */
+ static setDefaultMarchingOrder(leader) {
+ let items = [];
+ let next = leader;
+ while(next) {
+ let represents = next.get('represents');
+ if(!represents)
+ throw new Error('All tokens in the default marching order must represent a character.');
+
+ items.push({
+ represents,
+ imgsrc: next.get('imgsrc'),
+ name: next.get('name')
+ });
+ next = next.follower;
+ }
+ MarchingOrder.State.getState().defaultOrder = items;
+ }
+ };
+})();
+
+(() => {
+ 'use strict';
+
+ /**
+ * Installs/updates a macro for the script.
+ * @param {string} name
+ * @param {string} action
+ */
+ function _installMacro(player, name, action) {
+ let macro = findObjs({
+ _type: 'macro',
+ _playerid: player.get('_id'),
+ name
+ })[0];
+
+ if(macro)
+ macro.set('action', action);
+ else {
+ createObj('macro', {
+ _playerid: player.get('_id'),
+ name,
+ action
+ });
+ }
+ }
+
+ /**
+ * This module is responsible for installing and updating the macros
+ * used by this script.
+ */
+ MarchingOrder.Macros = class {
+
+ /**
+ * Installs/updates the macros for this script.
+ */
+ static installMacros() {
+ let players = findObjs({
+ _type: 'player'
+ });
+
+ const Commands = MarchingOrder.Commands;
+
+ // Create the macro, or update the players' old macro if they already have it.
+ _.each(players, player => {
+ _installMacro(player, 'MarchingOrderMenu', Commands.MENU_CMD);
+ });
+ }
+ };
+})();
+
+(() => {
+ 'use strict';
+
+ /**
+ * This module provides an interface to the script's state.
+ */
+ MarchingOrder.State = class {
+
+ /**
+ * Clears the script's state and resets it to its factory defaults.
+ */
+ static clearState() {
+ delete state.marchingOrder;
+ MarchingOrder.State.getState();
+ }
+
+ /**
+ * Displays the JSONified state for this script to the chat.
+ * @param {Player} player
+ * @return {string}
+ */
+ static exportState(player) {
+ let json = MarchingOrder.State.jsonifyState();
+ let content = `
Below is the JSON for this script's state. Copy-paste it to import it to another campaign.
` +
+ `[New Formation](' +
+ Commands.NEW_FORMATION_CMD + ' ' +
+ '?{Initial Marching Direction|north|south|east|west} ' +
+ '?{Give the formation a name.})' +
+ '
';
+
+ if(playerIsGM(playerId)) {
+ // Stop all following
+ actionsHtml += '' +
+ '[Clear Script State](' + Commands.CLEAR_STATE_CMD +
+ ' ?{Are you sure?|yes|no})
';
+ }
+
+ return new Menu('Marching Order', actionsHtml);
+ }
+
+ /**
+ * Show the main chat menu for the script to the given player.
+ * @param {Player} player
+ */
+ static showMainMenu(player) {
+ let menu = MarchingOrder.Wizard.getMainMenu(player);
+ menu.show(player);
+ }
+
+ /**
+ * Show the formations menu to the given player.
+ */
+ static _getFormationsHtml() {
+ let moState = MarchingOrder.State.getState();
+ const Commands = MarchingOrder.Commands;
+
+ if (_.size(moState.savedFormations) === 0) {
+ return '' +
+ 'No marching formations have been saved yet.
';
+ }
+
+ let actionsHtml = '[New Formation](' +
+ Commands.NEW_FORMATION_CMD + ' ' +
+ '?{Initial Marching Direction|north|south|east|west} ' +
+ '?{Give the formation a name.})' +
+ '
';
if(playerIsGM(playerId)) {
- // Cardinal directions (GM only)
- actionsHtml += '[Use Default](' + Commands.DEFAULT_USE_CMD + ') [Set Default](' + Commands.DEFAULT_SET_CMD + ' @{selected|token_id})
';
- }
- else {
- actionsHtml += 'No default order has been set.
';
- actionsHtml += '[Set Default](' + Commands.DEFAULT_SET_CMD + ' @{selected|token_id})';
- }
+ actionsHtml += '
' +
+ '[Stop All Following](' + Commands.STOP_ALL_CMD + ')
';
+
+ actionsHtml += '
';
+
+ // Show saved formations
+ actionsHtml += MarchingOrder.Wizard._getFormationsHtml();
+
+ actionsHtml += '
';
+
+ // Clear state
+ actionsHtml += '
' +
+ '[Clear Script State](' + Commands.CLEAR_STATE_CMD +
+ ' ?{Are you sure?|yes|no})
';
}
return new Menu('Marching Order', actionsHtml);
}
- static show(player) {
+ /**
+ * Show the main chat menu for the script to the given player.
+ * @param {Player} player
+ */
+ static showMainMenu(player) {
let menu = MarchingOrder.Wizard.getMainMenu(player);
menu.show(player);
}
+
+ /**
+ * Show the formations menu to the given player.
+ */
+ static _getFormationsHtml() {
+ let moState = MarchingOrder.State.getState();
+ const Commands = MarchingOrder.Commands;
+
+ if (_.size(moState.savedFormations) === 0) {
+ return '
' +
+ 'No marching formations have been saved yet.
';
+ }
+
+ let actionsHtml = '
Saved Formations:
';
+ actionsHtml += '
Previews of formations are shown marching westward.' +
+ '
';
+
+ // Get the sorted list of formation names.
+ let formationNames = _.map(moState.savedFormations, formation => {
+ return formation.name;
+ });
+ formationNames.sort();
+
+ let borderColor = '#c4a';
+
+ // Render each formation and its menu controls.
+ _.each(formationNames, name => {
+ let formation = moState.savedFormations[name];
+ actionsHtml += `
`;
+ actionsHtml += `
${formation.name}
`;
+ actionsHtml += MarchingOrder.Wizard._renderFormationPreview(formation);
+
+
+ // Render controls for the formation.
+ actionsHtml += '
';
+ actionsHtml += '
' +
+ '[Use](' + Commands.USE_FORMATION_CMD + ' ' + name + ')
';
+ actionsHtml += '
' +
+ '[Delete](' + Commands.DELETE_FORMATION_CMD + ' ' + name +
+ ' ?{Are you sure you want to delete formation ' + name +
+ '?|yes|no})
';
+ actionsHtml += "
";
+
+ actionsHtml += '
';
+ });
+
+ return actionsHtml;
+ }
+
+ /**
+ * Renders a preview of a formation to be displayed in the chat menu.
+ * @param {Formation} formation
+ */
+ static _renderFormationPreview(formation) {
+ let tokens = [{
+ imgsrc: formation.leaderImgSrc,
+ data: {
+ du: 0,
+ dv: 0
+ }
+ }];
+ _.each(formation.followers, follower => {
+ tokens.push(follower);
+ });
+
+ // Get the bounds of the formation.
+ let left = _.min(tokens, token => {
+ return token.data.du;
+ }).data.du;
+ let right = _.max(tokens, token => {
+ return token.data.du;
+ }).data.du;
+ let top = _.min(tokens, token => {
+ return token.data.dv;
+ }).data.dv;
+ let bottom = _.max(tokens, token => {
+ return token.data.dv;
+ }).data.dv;
+ let width = right - left + 70;
+ let height = bottom - top + 70;
+
+ // Determine the correct scale for the preview container.
+ let scale, previewWidth, previewHeight;
+ if (width > height) {
+ scale = 200/width || 1;
+ previewWidth = 200;
+ previewHeight = height*scale;
+ }
+ else {
+ scale = 200/height || 1;
+ previewWidth = width*scale;
+ previewHeight = 200;
+ }
+ let dia = 70*scale;
+
+ // Render the formation preview.
+ let previewHTML = `
`;
+
+ // Render the tokens.
+ _.each(tokens, follower => {
+ let unitSegment = [[0, 0, 1], [1, 0, 1]];
+ let xy = MarchingOrder.getFollowerOffset(unitSegment, follower.data.du, follower.data.dv);
+ xy = VecMath.add(xy, [-left, -top, 0]);
+ xy = VecMath.scale(xy, scale);
+
+ previewHTML += `

`;
+ });
+ previewHTML += '
';
+
+ return previewHTML;
+ }
};
})();
diff --git a/MarchingOrder/src/index.js b/MarchingOrder/src/index.js
index b633bb89c6..77ea1a24df 100644
--- a/MarchingOrder/src/index.js
+++ b/MarchingOrder/src/index.js
@@ -2,123 +2,405 @@ var MarchingOrder = (() => {
'use strict';
/**
- * Makes a token's followers move to the token's previous position.
- * @param {Graphic} leader
+ * A line segment consisting of 2 vec3s defining its endpoints.
+ * @typedef {vec3[]} Segment
+ */
+
+ /**
+ * A wrapper around a follower token. This includes positioning information
+ * for that token's place in its marching formation. For simplicity, think
+ * of the du, dv components in this way:
+ * Suppose the leader moves in a long, straight line westward, with the
+ * leader at the origin and the joined segments of their movement proceeding
+ * in the direction of the positive X axis. du would define the follower's
+ * position relative to this X axis, while dv would define the follower's
+ * position relative to the corresponding Y axis.
+ * @typedef {object} FollowerData
+ * @property {Graphic} token The follower token wrapped by this object.
+ * @property {number} du The projected offset of the token parallel to the
+ * leader's movement.
+ * @property {number} dv The projected offset of the token orthogonal
+ * to the leader's movement.
*/
- function _doFollowMovement(leader) {
- let follower = leader.follower;
- follower.prevLeft = follower.get("left");
- follower.prevTop = follower.get("top");
- follower.prevRotation = follower.get("rotation");
- follower.set("left",leader.prevLeft);
- follower.set("top",leader.prevTop);
- follower.set("rotation", leader.prevRotation);
+ // A mapping of cardinal direction names to their unit vectors.
+ const CARDINAL_VECTORS = {
+ 'north': [0, -1, 0],
+ 'south': [0, 1, 0],
+ 'east': [1, 0, 0],
+ 'west': [-1, 0, 0]
+ };
+
+ // The maximum number of segments to track in a leader token's movement.
+ const MAX_SEGMENTS = 20;
- if(typeof CustomStatusMarkers !== 'undefined')
- CustomStatusMarkers.repositionStatusMarkers(follower);
+ /**
+ * Delete a saved marching formation.
+ * @param {string} name The name of the formation.
+ */
+ function deleteFormation(name) {
+ let moState = MarchingOrder.State.getState();
+ delete moState.savedFormations[name];
}
/**
- * Makes a single token follow another token.
- * @param {Graphic} leader
- * @param {Graphic} follower
+ * Get the token persisted in the state. Search first by the token's
+ * character's ID, then by the token's name.
+ * @param {string} id The character ID or token name.
+ * return {Graphic}
+ */
+ function _findPersistedToken(id) {
+ // First try to get the token by its character's ID.
+ let token;
+ let character = findObjs({
+ _type: "character",
+ name: id
+ })[0];
+ if (character) {
+ let tokens = findObjs({
+ _pageid: Campaign().get("playerpageid"),
+ _type: "graphic",
+ represents: character.get('_id')
+ });
+ if (tokens.length === 1)
+ return tokens[0];
+ }
+
+ // Next, try to get the token by its name.
+ if (!token) {
+ let tokens = findObjs({
+ _pageid: Campaign().get("playerpageid"),
+ _type: "graphic",
+ name: id
+ });
+ if (tokens.length === 1)
+ return tokens[0];
+ else if (tokens.length > 1)
+ throw new Error(`Could not find unique token with ID ${id}. ` +
+ `Please make sure there aren't any duplicates on the map.`);
+ }
+ throw new Error(`Token with ID ${id} does not exist.`);
+ }
+
+ /**
+ * Get the token used for persisting a token in a saved formation.
+ * Tokens are persisted by their character name or by their token name
+ * (in that order).
+ * @param {Graphic} token
+ * @return {string}
+ */
+ function _getTokenPersistID(token) {
+ let id = token.get('name');
+ let character = getObj('character', token.get('represents'));
+ if (character)
+ id = character.get('name');
+
+ if (!id)
+ throw new Error(`Token ${token.get('_id')} does not have a valid ` +
+ `persistance identifier (must be a unique character name or ` +
+ `token name)`);
+ return id;
+ }
+
+ /**
+ * Gets the point of a marching offset relative to a line segment,
+ * where the segment's starting point is closer to the front of the
+ * formation and the end point is closer to the rear of the formation.
+ * @param {Segment} segment
+ * @param {number} du The projected offset parallel to the segment.
+ * @param {number} dv The projected offset orthogonal to the segment.
+ * @return {vec3}
*/
- function follow(leader, follower) {
- if(!leader || !follower)
- return;
+ function getFollowerOffset(segment, du, dv) {
+ let [p1, p2] = segment;
+ let u = VecMath.sub(p2, p1);
+ let uHat = VecMath.normalize(u);
+ let uLen = VecMath.length(u);
+ let vHat = VecMath.cross([0, 0, 1], uHat);
- // unbind all of follower's following links.
- unfollow(follower);
+ let alpha = du/uLen;
- let prevFollower = leader.follower;
- follower.leader = leader;
- follower.follower = prevFollower;
+ // Find the token's new position projected parallel to the segment.
+ let xy = VecMath.add(p1, VecMath.scale(u, alpha));
- leader.follower = follower;
- if(prevFollower)
- prevFollower.leader = follower;
+ // Then offset it by the token's orthogonal projection.
+ return VecMath.add(xy, VecMath.scale(vHat, dv));
}
/**
- * Makes tokens follow each other in order of some compass direction.
- * @param {Graphic[]} tokens
- * @param {String} direction
- */
- function followAllDirection(tokens, direction) {
- tokens.sort((a,b) => {
- let aX = parseFloat(a.get("left"));
- let bX = parseFloat(b.get("left"));
- let aY = parseFloat(a.get("top"));
- let bY = parseFloat(b.get("top"));
-
- if(direction === "north")
- return (aY - bY);
- else if(direction === "south")
- return (bY - aY);
- else if(direction === "west")
- return (aX - bX);
- else // east
- return (bX - aX);
+ * Get the token's position as a vector.
+ * @param {Graphic} token
+ * @return {vec3}
+ */
+ function _getTokenPt(token) {
+ let x = token.get('left');
+ let y = token.get('top');
+ return [x, y, 1];
+ }
+
+ /**
+ * Convert a token's lastmove string into a list of line segments, with
+ * the points in order from last to first (end of movement to start of
+ * movement).
+ * @param {Graphic} token The token we're converting the lastmove of.
+ * @return {Segment[]}
+ */
+ function _lastmove2ReverseSegments(token) {
+ let lastmove = token.get('lastmove');
+
+ // Parse the coordinands out of the lastmove string.
+ let coordinands = _.map(lastmove.split(','), x => {
+ return parseInt(x);
+ });
+
+ // Append the token's current position.
+ coordinands.push(parseInt(token.get('left')));
+ coordinands.push(parseInt(token.get('top')));
+
+ // Convert the coordinand pairs into points in homogeneous coordinates, in
+ // reverse order.
+ let points = _.chain(coordinands)
+ .chunk(2)
+ .map(pt => {
+ return [...pt, 1];
+ })
+ .reverse()
+ .value();
+
+ // Convert the points into a list of segments.
+ return _.map(_.range(points.length - 1), i => {
+ let start = points[i];
+ let end = points[i + 1];
+ return [start, end];
});
- setMarchingOrder(tokens);
}
/**
- * Makes a chain of tokens follow some other token.
- * @param {Graphic} follower
+ * Initializes a leader token for a marching formation.
* @param {Graphic} leader
*/
- function followAllToken(follower, leader) {
- // Can't follow self.
- if(follower === leader)
- return;
-
- // Include the follower's previous followers in the marching order.
- let tokens = [follower];
- let next = follower.follower;
- while(next) {
- if(next === leader)
- throw new Error('Cyclical marching orders are not allowed!');
- tokens.push(next);
- next = next.follower;
+ function _initLeader(leader) {
+ // Have all the tokens involved leave any formation they were previously in.
+ unfollow(leader);
+ _.each(leader.followers, follower => {
+ unfollow(follower.token);
+ });
+
+ // Reset the leader's marching properties.
+ leader.followers = [];
+ leader.moveSegments = [];
+ }
+
+ /**
+ * Move a leader's followers in formation relative to their
+ * leader's last movement.
+ * @param {Graphic} leader The leader token.
+ */
+ function moveInFormation(leader) {
+ // Convert the leader's lastmove into a list of line segments and prepend
+ // those to an accumulated list.
+ let prevSegments = _.clone(leader.moveSegments);
+ let curSegments = _lastmove2ReverseSegments(leader);
+ leader.moveSegments =
+ _.first([...curSegments, ...prevSegments], MAX_SEGMENTS);
+
+ // Compute the total distance of the leader's tracked movement.
+ let totalMoveDist = _.reduce(leader.moveSegments, (memo, segment) => {
+ let [p1, p2] = segment;
+ return memo + VecMath.dist(p1, p2);
+ }, 0);
+
+ // Have each follower follow in formation with the leader.
+ _.each(leader.followers, follower => {
+ // If the projected distance is farther than the total tracked movement
+ // distance, then just march the follower relative to the last segment.
+ if (follower.data.du >= totalMoveDist) {
+ let lastSeg = _.last(leader.moveSegments);
+ let lastDist = VecMath.dist(lastSeg[0], lastSeg[1]);
+
+ let relDist = (follower.data.du - totalMoveDist) + lastDist;
+
+
+ let xy = getFollowerOffset(lastSeg, relDist, follower.data.dv);
+ _setNewFollowerPosition(leader, follower.token, xy);
+ }
+
+ // Otherwise, find the segment upon which the projected distance would
+ // lie, and march the follower relative to that one.
+ else {
+ let currDist = follower.data.du;
+
+ // Find the segment which the follower's projected distance lies upon.
+ _.find(leader.moveSegments, segment => {
+ let [p1, p2] = segment;
+ let u = VecMath.sub(p2, p1);
+ let uLen = VecMath.length(u);
+
+ if (currDist < uLen) {
+ let xy = getFollowerOffset(segment, currDist, follower.data.dv);
+ _setNewFollowerPosition(leader, follower.token, xy);
+ return true;
+ }
+ else
+ currDist -= uLen;
+ });
+ }
+ });
+ }
+
+ /**
+ * Sets the new position for a token in a formation while also doing
+ * token collision with dynamic lighting walls.
+ * @param {Graphic} leader
+ * @param {Graphic} token
+ * @param {vec3} newXY
+ */
+ function _setNewFollowerPosition(leader, token, newXY) {
+ // Set the new position based on the formation movement.
+ token.set('left', newXY[0]);
+ token.set('top', newXY[1]);
+
+ // Check for collisions with dynamic lighting and update the position if
+ // necessary.
+ token.set('lastmove', leader.get('lastmove'));
+ let walls = findObjs({
+ _type: 'path',
+ _pageid: Campaign().get("playerpageid"),
+ layer: 'walls'
+ });
+ let collision =
+ TokenCollisions.getCollisions(token, walls, { detailed: true })[0];
+ if (collision) {
+ token.set('left', collision.pt[0]);
+ token.set('top', collision.pt[1]);
}
- tokens.unshift(leader);
- setMarchingOrder(tokens);
}
+ /**
+ * Create a marching order formation for the given tokens and set them to
+ * follow that formation. Then save it for future use.
+ * @param {string} name The name of the new foramtion.
+ * @param {Graphic} leader The leader of the formation. When the leader
+ * moves, the other tokens will move in that formation.
+ * @param {Graphic[]} followers The tokens that will be following the leader.
+ * @param {string} direction A cardinal direction (north, south, east, or
+ * west) given as the direction the formation is facing at the time it is
+ * created.
+ */
+ function newFormation(name, leader, followers, direction) {
+ let dirVector = CARDINAL_VECTORS[direction.toLowerCase()];
+ if (!dirVector)
+ throw new Error(`${direction} is an invalid cardinal direction.`);
+
+ // Get the vector pointing in the opposition direction from the cardinal
+ // direction.
+ let uHat = VecMath.scale(dirVector, -1);
+
+ // Remove the leader from the list of followers if it was included there.
+ followers = _.reject(followers, follower => {
+ return follower === leader;
+ });
+
+ // Set up the formation data for the leader and its followers.
+ _initLeader(leader);
+ _.each(followers, token => {
+ // Get the vector from the leader to the follower.
+ let tokenXY = _getTokenPt(token);
+ let leaderXY = _getTokenPt(leader);
+ let a = VecMath.sub(tokenXY, leaderXY);
+
+ // Determine the projected distance (du) of the
+ // follower behind the leader.
+ let du = VecMath.scalarProjection(uHat, a);
+ // Determine the orthogonal projected distance (dv) of the
+ // follower behind the leader.
+ let vHat = VecMath.cross([0, 0, 1], uHat);
+ let dv = VecMath.scalarProjection(vHat, a);
+
+ _setFormationOnToken(leader, token, {du, dv});
+ });
+
+ // Save the formation for future use.
+ saveFormation(name, leader);
+ MarchingOrder.utils.Chat.broadcast(`Defined new marching formation ` +
+ `${name} with ${leader.get('name')} as the leader.`);
+ }
/**
- * Sets a marching order for an array of tokens, with the token at index 0
- * being the leader.
- * @param {Graphic[]}
+ * Persist a marching formation defined by some leader token and its
+ * followers.
+ * @param {string} name The name of the formation.
+ * @param {Graphic} leader The leader token for the formation.
*/
- function setMarchingOrder(tokens) {
- _.each(_.range(tokens.length-1), i => {
- let leader = tokens[i];
- let follower = tokens[i+1];
+ function saveFormation(name, leader) {
+ let myState = MarchingOrder.State.getState();
+ if (myState.savedFormations[name])
+ throw new Error(`A formation named ${name} already exists!`);
+
+ // Track the leader by either its character's ID, or by its token name
+ // (in that order).
+ let leaderID = _getTokenPersistID(leader);
+ if (!leader.followers)
+ throw new Error(`Could not save the formation. The leader token has ` +
+ `no followers.`);
- MarchingOrder.utils.Chat.broadcast(follower.get("name") + " is following " + leader.get("name"));
- follow(leader, follower);
+ // Convert the followers for persistence.
+ let followers = _.map(leader.followers, follower => {
+ // Track the follower by their character ID or by their token name
+ // (in that order).
+ let followerID = _getTokenPersistID(follower.token);
+ let imgsrcRaw = follower.token.get('imgsrc');
+ let imgsrc = MarchingOrder.utils.getCleanImgsrc(imgsrcRaw);
+
+ return {
+ id: followerID,
+ imgsrc,
+ data: follower.data
+ };
});
+
+ // Persist the formation under the given name.
+ let leaderImgSrcRaw = leader.get('imgsrc');
+ let leaderImgSrc = MarchingOrder.utils.getCleanImgsrc(leaderImgSrcRaw);
+ myState.savedFormations[name] = {
+ name,
+ leaderID,
+ leaderImgSrc,
+ followers
+ };
+ MarchingOrder.utils.Chat.broadcast(`Saved formation "${name}"!`);
}
/**
- * Makes a token stop following other tokens.
- * @param {Graphic} token
+ * Set up a single token to follow its leader with the given formation data.
+ * @param {Graphic} leader The leader token.
+ * @param {Graphic} token The follower token.
+ * @param {object} data An object containing the individual formation data
+ * for token.
+ */
+ function _setFormationOnToken(leader, token, data) {
+ token.leader = leader;
+ leader.followers.push({token, data});
+ }
+
+ /**
+ * The given token leaves any marching order formation it is currently
+ * following.
+ * @param {Graphic} token The token that is leaving its marching formation.
*/
function unfollow(token) {
- if(token.leader)
- token.leader.follower = token.follower;
- if(token.follower)
- token.follower.leader = token.leader;
- token.leader = null;
- token.follower = null;
+ let leader = token.leader;
+ if (leader && leader.followers) {
+ // Remove the token from its leader's list of followers.
+ leader.followers = _.reject(leader.followers, follower => {
+ return follower.token === token;
+ });
+ }
}
/**
- * Makes all tokens stop following each other.
+ * Make all tokens leave their marching formation.
*/
function unfollowAll() {
let allObjs = findObjs({
@@ -126,69 +408,87 @@ var MarchingOrder = (() => {
layer: 'objects'
});
_.each(allObjs, obj => {
- unfollow(obj);
+ obj.leader = undefined;
+ obj.followers = undefined;
});
- MarchingOrder.utils.Chat.broadcast("Tokens are no longer following each other.");
+ MarchingOrder.utils.Chat.broadcast(
+ "Ceased all active marching formations.");
}
/**
- * Applies the default marching order to the page that currently has the
- * player ribbon.
- */
- function useDefaultMarchingOrder() {
- let playerpageid = Campaign().get('playerpageid');
- let tokens = [];
- let defaultOrder = MarchingOrder.Config.getDefaultMarchingOrder();
- _.each(defaultOrder, item => {
- let token = findObjs({
- _type: 'graphic',
- _pageid: playerpageid,
- represents: item.represents
- })[0];
- if(token)
- tokens.push(token);
+ * Load a persisted formation and apply it to its leader and followers.
+ * @param {string} name The name of the formation.
+ */
+ function useFormation(name) {
+ let myState = MarchingOrder.State.getState();
+ let formation = myState.savedFormations[name];
+ if (!formation)
+ throw new Error(`Formation "${name}" doesn't exist.`);
+
+ // Load the token for the leader.
+ let leader = _findPersistedToken(formation.leaderID);
+
+ // Load the leader's followers.
+ _initLeader(leader);
+ _.each(formation.followers, follower => {
+ try {
+ let token = _findPersistedToken(follower.id);
+ _setFormationOnToken(leader, token, follower.data);
+ }
+ catch (err) {
+ // Warn the GM, but skip the token if it isn't available.
+ MarchingOrder.utils.Chat.warn(err);
+ }
});
- setMarchingOrder(tokens);
+ MarchingOrder.utils.Chat.broadcast(`Using formation "${name}", ` +
+ `with ${formation.leaderID} as the leader!`);
}
-
// When the API is loaded, install the Custom Status Marker menu macro
// if it isn't already installed.
on('ready', () => {
- MarchingOrder.Macros.installMacros();
- log('--- Initialized Marching Order vSCRIPT_VERSION ---');
+ try {
+ if(!VecMath)
+ throw new Error("The new version of the Marching Order script " +
+ "requires the Vector Math script. Go install it, or be plagued " +
+ "with errors!");
+ if (!TokenCollisions)
+ throw new Error("The new version of the Marching Order script " +
+ "requires the Token Collisions script. Go install it, " +
+ "or be plagued with errors!");
+
+ let moState = MarchingOrder.State.getState();
+ if (!moState.version)
+ MarchingOrder.utils.Chat.broadcast("Hello, friend! The Marching " +
+ "Order script has undergone a big update since version 3.0. " +
+ "Please check the docs to check out what's new!");
+
+ moState.version = 'SCRIPT_VERSION';
+ MarchingOrder.Macros.installMacros();
+ log('--- Initialized Marching Order vSCRIPT_VERSION ---');
+ }
+ catch (err) {
+ MarchingOrder.utils.Chat.error(err);
+ }
});
/**
* Set up an event handler to do the marching order effect when the
* leader tokens move!
*/
- on("change:graphic", (obj, prev) => {
+ on("change:graphic:lastmove", obj => {
try {
- let leader = obj;
- leader.prevLeft = prev["left"];
- leader.prevTop = prev["top"];
- leader.prevRotation = prev["rotation"];
-
- // Only move the followers if there was a change in either the leader's
- // left or top attributes.
- if(leader.get("left") !== leader.prevLeft || leader.get("top") !== leader.prevTop) {
-
- // We stepped out of line. Stop following the guy in front of us.
- if(leader.leader)
- unfollow(leader);
-
- // move everyone to the previous position of the token in front of them.
- while(leader.follower) {
- _doFollowMovement(leader);
- leader = leader.follower;
-
- // avoid cycles.
- if(leader === obj)
- return;
- }
- }
+ let token = getObj('graphic', obj.get('_id'));
+
+ // If the token has followers, have them move in formation behind it.
+ if (token.followers)
+ moveInFormation(token);
+
+ // Otherwise, if the token was following in some formation, leave the
+ // formation.
+ else if (token.leader)
+ unfollow(token);
}
catch(err) {
MarchingOrder.utils.Chat.error(err);
@@ -196,12 +496,13 @@ var MarchingOrder = (() => {
});
return {
- follow: follow,
- followAllDirection,
- followAllToken,
- set: setMarchingOrder,
+ deleteFormation,
+ getFollowerOffset,
+ moveInFormation,
+ newFormation,
+ saveFormation,
unfollow,
unfollowAll,
- useDefaultMarchingOrder
+ useFormation
};
})();
diff --git a/MarchingOrder/src/utils/Chat.js b/MarchingOrder/src/utils/Chat.js
index e9a93b70e3..4f6b9be20b 100644
--- a/MarchingOrder/src/utils/Chat.js
+++ b/MarchingOrder/src/utils/Chat.js
@@ -37,6 +37,48 @@
return srcWho.replace(/\(GM\)/, '').trim();
}
+ /**
+ * Extracts the selected graphics from a chat message.
+ * @param {ChatMessage} msg
+ * @return {Graphic[]}
+ */
+ static getGraphicsFromMsg(msg) {
+ var result = [];
+
+ var selected = msg.selected;
+ if(selected) {
+ _.each(selected, s => {
+ let graphic = getObj('graphic', s._id);
+ if(graphic)
+ result.push(graphic);
+ });
+ }
+ return result;
+ }
+
+ /**
+ * Publicly shame a player for trying to use a GMs-only part of this script.
+ * @param {Player} player
+ * @param {string} component A descriptor of the component the player tried
+ * to access.
+ */
+ static tattle(player, component) {
+ let name = player.get('_displayname');
+ MarchingOrder.utils.Chat.broadcast(`Player ${name} has been caught ` +
+ `accessing a GMs-only part of the Marching Order ` +
+ `script: ${component}. Shame on them!`);
+ }
+
+ /**
+ * Notify GMs about a warning.
+ * @param {Error} err
+ */
+ static warn(err) {
+ log(`MarchingOrder WARNING: ${err.message}`);
+ MarchingOrder.utils.Chat.whisperGM(
+ `WARNING: ${err.message}`);
+ }
+
/**
* Whispers a message to someoen.
* @param {Player} player The player who will receive the whisper.
diff --git a/MarchingOrder/src/utils/index.js b/MarchingOrder/src/utils/index.js
index 8ae82bdc4f..08eee812ea 100644
--- a/MarchingOrder/src/utils/index.js
+++ b/MarchingOrder/src/utils/index.js
@@ -9,12 +9,13 @@
* https://wiki.roll20.net/API:Cookbook#getCleanImgsrc
*/
function getCleanImgsrc(imgsrc) {
- let parts = imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)(.*)$/);
+ let parts = imgsrc.match(/(.*\/(images|marketplace)\/.*)(thumb|med|original|max)(.*)$/);
if(parts)
- return parts[1]+'thumb'+parts[3];
+ return parts[1]+'thumb'+parts[4];
throw new Error('Only images that you have uploaded to your library ' +
'can be used as custom status markers. ' +
- 'See https://wiki.roll20.net/API:Objects#imgsrc_and_avatar_property_restrictions for more information.');
+ 'See https://wiki.roll20.net/API:Objects#imgsrc_and_avatar_property_restrictions for more information. ' +
+ 'Offending URL: ' + imgsrc);
}
MarchingOrder.utils = {
diff --git a/Token Collisions/1.6/TokenCollisions.js b/Token Collisions/1.6/TokenCollisions.js
new file mode 100644
index 0000000000..41a1171c13
--- /dev/null
+++ b/Token Collisions/1.6/TokenCollisions.js
@@ -0,0 +1,896 @@
+/**
+ * A small library for testing collisions between moving tokens in Roll20.
+ */
+var TokenCollisions = (() => {
+ 'use strict';
+
+ /**
+ * An object encapsulating a collision between two tokens and the point
+ * of collision.
+ * @typedef {object} Collision
+ * @property {Graphic} token
+ * The token that moved.
+ * @property {Graphic} other
+ * The token it collided with.
+ * @property {vec2} pt
+ * The point of collision.
+ * @property {number} dist
+ * The distance of the collision from the its waypoint start.
+ */
+
+ /**
+ * An object with optional parameters for collision functions.
+ * @typedef {object} CollisionOptions
+ * @property {boolean} detailed
+ * If true, the collision function will return Collision objects
+ * instead instead of Tokens that were collided with.
+ */
+
+ /**
+ * A movement waypoint defined by two points: The starting point and the
+ * movement's endpoint.
+ * @typedef {vec2[]} waypoint
+ */
+
+ /**
+ * For some token, this gets the list of tokens it collided with during its
+ * movement between two points, from some list of other tokens.
+ * The tokens are sorted in the order that they are collided with.
+ * @private
+ * @param {Graphic} token
+ * @param {Graphic[]} others
+ * @param {waypoint} waypoint
+ * @param {object} [options]
+ * @return {(Graphic[]|Collisions[])}
+ */
+ function _getCollisionsInWaypoint(token, others, waypoint, options) {
+ options = options || {};
+ let start = waypoint[0];
+ let end = waypoint[1];
+
+ let numCollisions = 0;
+
+ return _.chain(others)
+
+ // Get the list of tokens that actually collide, sorted by the distance
+ // from the starting point at which they occur.
+ .map(other => {
+ let dist = _testCollision(token, other, waypoint);
+ if(dist !== undefined && dist >= 0) {
+ numCollisions++;
+
+ let alpha = dist/VecMath.dist(start, end);
+ let vec = VecMath.sub(end, start);
+ vec = VecMath.scale(vec, alpha);
+ let pt = VecMath.add(start, vec);
+
+ return { token, other, dist, pt };
+ }
+ return undefined;
+ })
+ .sortBy(collision => {
+ if(collision === undefined)
+ return undefined;
+ return collision.dist;
+ })
+
+ // Other tokens with undefined collision distance will be sorted to the
+ // high end of the list. So, we'll just drop them.
+ .first(numCollisions)
+ .map(collision => {
+ if(options.detailed)
+ return collision;
+ return collision.other
+ })
+ .value();
+ }
+
+ /**
+ * Gets the list of all points traversed during a token's movement, including
+ * its current position at the end of the movement.
+ * @private
+ * @param {Graphic} token
+ * @return {vec2[]}
+ */
+ function _getLastMovePts(token) {
+ let move = token.get('lastmove').split(',');
+ let coords = _.map(move, x => {
+ return parseInt(x);
+ });
+
+ let pts = _.map(_.range(coords.length/2), i => {
+ let x = coords[i*2];
+ let y = coords[i*2 + 1];
+ return [x, y];
+ });
+ pts.push([token.get('left'), token.get('top')]);
+ return pts;
+ }
+
+ /**
+ * Gets the position of a token.
+ * @private
+ * @param {Graphic} token
+ * @return {vec2}
+ */
+ function _getPt(token) {
+ return [token.get('left'), token.get('top')];
+ }
+
+ /**
+ * Gets the list of all points traversed during a token's movement, including
+ * its current position at the end of the movement.
+ * @private
+ * @param {Graphic} token
+ * @return {waypoint[]}
+ */
+ function _getWaypoints(token) {
+ let prev;
+ let waypoints = [];
+ _.each(_getLastMovePts(token), pt => {
+ if(prev)
+ waypoints.push([prev, pt]);
+ prev = pt;
+ });
+ return waypoints;
+ }
+
+ /**
+ * Checks if a circle is overlapping a circle.
+ * @private
+ * @param {graphic} token
+ * @param {graphic} other
+ * @param {int} inset
+ * @return {Boolean}
+ */
+ function _isOverlappingCircleCircle(token, other, inset) {
+ let circle1 = _tokenToCircle(token, inset);
+ let circle2 = _tokenToCircle(other, inset);
+ return circle1.intersects(circle2);
+ }
+
+ /**
+ * Checks if a circle is overlapping a path/polygon.
+ * The path is treated as a polygon if its fill is not transparent.
+ * @private
+ * @param {graphic} token
+ * @param {path} path
+ * @param {int} inset
+ * @return {boolean}
+ */
+ function _isOverlappingCirclePath(token, path, inset) {
+ let circle = _tokenToCircle(token, inset);
+
+ if(path.get('fill') === 'transparent') {
+ let segments = PathMath.toSegments(path);
+ return !!_.find(segments, seg => {
+ return circle.segmentIntersection(seg);
+ });
+ }
+ else {
+ let poly = new PathMath.Polygon(path);
+ return circle.intersectsPolygon(poly);
+ }
+ }
+
+ /**
+ * Checks if a circle is overlapping a rectangle.
+ * @private
+ * @param {graphic} token
+ * @param {graphic} other
+ * @param {int} inset
+ * @return {Boolean}
+ */
+ function _isOverlappingCircleRect(token, other, inset) {
+ let circle = _tokenToCircle(token, inset);
+ let rect = _tokenToRect(other, inset);
+ return circle.intersectsPolygon(rect);
+ }
+
+ /**
+ * Checks if a rectangle is overlapping a path/polygon.
+ * The path is treated as a polygon if its fill is not transparent.
+ * @private
+ * @param {graphic} token
+ * @param {path} path
+ * @param {int} inset
+ * @return {boolean}
+ */
+ function _isOverlappingRectPath(token, path, inset) {
+ let rect = _tokenToRect(token, inset);
+
+ if(path.get('fill') === 'transparent')
+ return rect.intersectsPath(path);
+ else {
+ let poly = new PathMath.Polygon(path);
+ return rect.intersects(poly);
+ }
+ }
+
+ /**
+ * Checks if a rectangle is overlapping a rectangle.
+ * @param {graphic} token
+ * @param {graphic} other
+ * @param {int} inset
+ * @return {Boolean}
+ */
+ function _isOverlappingRectRect(token, other, inset) {
+ let rect1 = _tokenToRect(token, inset);
+ let rect2 = _tokenToRect(other, inset);
+ return rect1.intersects(rect2);
+ }
+
+ /**
+ * Tests if a circular token collides with another circular token during
+ * its last movement.
+ * @param {Graphic} token
+ * @param {Graphic} other
+ * @param {waypoint} waypoint
+ * @return {number}
+ * The distance along the waypoint at which the collision happens,
+ * or undefined if there is no collision.
+ */
+ function _testCirclesCollision(token, other, waypoint) {
+ let start = waypoint[0];
+ start[2] = 1;
+ let end = waypoint[1];
+ end[2] = 1;
+ let pt = _getPt(other);
+ pt[2] = 1;
+ let segment = [start, end];
+
+ let tokenR = token.get('width')/2;
+ let otherR = other.get('width')/2;
+ let totalR = tokenR + otherR;
+
+ // Reduce the problem to an intersection between a combined circle and
+ // the segment representing the waypoint.
+ let circle = new PathMath.Circle(pt, totalR-1); // inset by 1 to avoid edges.
+ let intersection = circle.segmentIntersection(segment);
+ if(intersection) {
+ let intPt = intersection[0];
+ let scalar = intersection[1];
+
+ // If our movement started in other's circle, then it doesn't count.
+ if(scalar <= 0)
+ return undefined;
+
+ // Return the distance from the start to the point of intersection.
+ return VecMath.dist(start, intPt);
+ }
+ }
+
+ /**
+ * Tests for a collision between a circle and a Path.
+ * If the path's fill is not transparent, then the Path is treated as a
+ * Polygon.
+ * @private
+ * @param {graphic} token
+ * @param {Path} path
+ * @param {Waypoint} waypoint
+ */
+ function _testCirclePathCollision(token, path, waypoint) {
+ let shape;
+ if(path.get('fill') === 'transparent')
+ shape = new PathMath.Path(path);
+ else
+ shape = new PathMath.Polygon(path);
+
+ return _testCirclePolyCollision(token, shape, waypoint);
+ }
+
+ /**
+ * Tests for a collision between a circle and a Polygon.
+ * @private
+ * @param {graphic} token
+ * @param {(PathMath.Polygon|PathMath.Path)} poly
+ * @param {Waypoint} waypoint
+ * @return {number} The minimum distance.
+ */
+ function _testCirclePolyCollision(token, poly, waypoint) {
+ let start = _.clone(waypoint[0]);
+ start[2] = 1;
+ let end = _.clone(waypoint[1]);
+ end[2] = 1;
+ let u = VecMath.sub(end, start);
+ let uLen = VecMath.length(u);
+ let uAngle = Math.atan2(u[1], u[0]);
+ let radius = token.get('width')/2 - 1; // Inset 1 to avoid edges.
+
+ // Quit early if the polygon's bounding box does not intersect the
+ // union of the start and end circles' bounding boxes.
+ let startCircle = new PathMath.Circle(start, radius);
+ let startBox = startCircle.getBoundingBox();
+ let endCircle = new PathMath.Circle(end, radius);
+ let endBox = endCircle.getBoundingBox();
+
+ let moveBox = PathMath.BoundingBox.add(startBox, endBox);
+ let polyBox = poly.getBoundingBox();
+
+ if(!moveBox.intersects(polyBox))
+ return undefined;
+
+ // Quit early if the polygon contains the start circle's center.
+ if(poly instanceof PathMath.Polygon && poly.containsPt(startCircle.center))
+ return undefined;
+
+ // Produce a system transformation such that our circle is centered at
+ // the origin and u points up. Then transform the polygon to this system.
+ let rotation = Math.PI/2 - uAngle;
+ let m = MatrixMath.multiply(
+ MatrixMath.rotate(rotation),
+ MatrixMath.translate(VecMath.sub([0,0,1] ,start))
+ );
+ let mPoly = poly.transform(m);
+ let mCircle = new PathMath.Circle([0,0,1], radius);
+
+ // Return the minimum collision distance to a transformed segment.
+ let segments = mPoly.toSegments();
+ let keptSegs = _testCirclePolyCollision_clipSegments(segments, mCircle);
+
+ let minDist = _testCirclePolyCollision_minDistance(keptSegs, radius);
+ if(minDist === Infinity || minDist > uLen)
+ return undefined;
+ return minDist;
+ }
+
+ // Clip out segments that extend beyond +/-radius. Also, get their line
+ // equation data.
+ function _testCirclePolyCollision_clipSegments(segments, circle) {
+ let radius = circle.radius;
+ return _.chain(segments)
+ .map(seg => {
+ let p = seg[0];
+ let q = seg[1];
+
+ // Keep vertical segments that lie within the radius.
+ if(p[0] === q[0]) {
+ if(p[0] > -radius && p[0] < radius) {
+ seg.m = undefined;
+ seg.b = undefined;
+ return seg;
+ }
+ }
+
+ // Let p be the leftmost point.
+ if(p[0] > q[0]) {
+ let swap = q;
+ q = p;
+ p = swap;
+ }
+
+ // Get the line equation info.
+ let dx = q[0] - p[0];
+ let dy = q[1] - p[1];
+ let m = dy/dx;
+ let b = p[1] - m*p[0];
+
+ // Clip the segment if it intersects the starting circle.
+ if(circle.segmentIntersection(seg))
+ return;
+
+ // Clip the segment if both points are under the circle.
+ if(p[1] < 0 && q[1] < 0)
+ return;
+
+ // Clip the segment if both points are on the same side beyond the radius.
+ if(p[0] < -radius && q[0] < -radius)
+ return;
+ else if(p[0] > radius && q[0] > radius)
+ return;
+
+ // Clip at intersections with the left and right radius pillars.
+ else {
+ if(p[0] < -radius)
+ p = [-radius, -m*radius + b, 1];
+ if(q[0] > radius)
+ q = [radius, m*radius + b, 1];
+ }
+
+ let clippedSeg = [p, q];
+ clippedSeg.m = m;
+ clippedSeg.b = b;
+ return clippedSeg;
+ })
+ .compact()
+ .value();
+ }
+
+ // Using the power of calculus, find the closest segment that
+ // wasn't clipped.
+ function _testCirclePolyCollision_minDistance(segments, radius) {
+ return _.chain(segments)
+ .map(seg => {
+ let p = seg[0];
+ let q = seg[1];
+
+ let fofX = x => { // The line equation for the segment in y=mx + b form.
+ return seg.m*x + seg.b;
+ };
+ let gofX = x => { // The line equation for the upper half of the circle.
+ return Math.sqrt(radius*radius - x*x);
+ };
+ let hofX = x => { // Collision distance equation.
+ return fofX(x) - gofX(x);
+ };
+ let hofXdx = x => { // first derivative.
+ return seg.m + x/gofX(x);
+ };
+ let hofXddx = x => { // second derivative.
+ return radius*radius/Math.pow(gofX(x), 3);
+ };
+
+ if(seg.m === undefined)
+ return Math.min(seg[0][1], seg[1][1]) - gofX(seg[0][0]);
+ else {
+ let root1 = seg.m*radius/Math.sqrt(1 + seg.m*seg.m);
+ let root2 = -root1;
+
+ // Clip roots outside of the segment, on the edge of the
+ // circle's movement, or whose slopes aren't valleys.
+ // Then get the collision distance to the closest root.
+ let minDist = _.chain([root1, root2, p[0], q[0]])
+ .filter(root => {
+ let isInRadius = (root >= -radius && root <= radius);
+ let isInSegment = (root >= p[0] && root <= q[0]);
+ let isValley = (hofXddx(root) > 0);
+
+ return isInRadius && isInSegment && isValley;
+ })
+ .map(root => {
+ let result = hofX(root);
+ return hofX(root);
+ })
+ .min() // Infinity if no valid roots.
+ .value();
+
+ if(minDist > 0)
+ return minDist;
+ return undefined;
+ }
+ })
+ .min() // Get the shortest distance among the segments.
+ .value();
+ }
+
+ /**
+ * Tests if a circular token collides with a rectangular token during its
+ * last movement.
+ * @param {Graphic} token
+ * @param {Graphic} other
+ * @param {waypoint} waypoint
+ * @return {number}
+ * The distance along the waypoint at which the collision happens,
+ * or undefined if there is no collision.
+ */
+ function _testCircleRectCollision(token, other, waypoint) {
+ let rect = _tokenToRect(other);
+ return _testCirclePolyCollision(token, rect, waypoint);
+ }
+
+ /**
+ * Tests for a collision between a token and another object using a strategy
+ * appropriate for the shapes of the colliding objects.
+ * @private
+ * @param {Graphic} token
+ * @param {(Graphic|Path|PathMath.Polygon)} other
+ * @param {Waypoint} waypoint
+ */
+ function _testCollision(token, other, waypoint) {
+ let isSquare = token.get('aura1_square');
+ let otherIsPath = (other.get('_type') === 'path');
+
+ let strategy;
+ if(isSquare)
+ if(other instanceof PathMath.Polygon)
+ strategy = _testRectPolyCollision;
+ else if(other.get('_type') === 'path')
+ strategy = _testRectPathCollision;
+ else if(other.get('aura1_square'))
+ strategy = _testRectsCollision;
+ else
+ strategy = _testRectCircleCollision;
+ else
+ if(other instanceof PathMath.Polygon)
+ strategy = _testCirclePolyCollision;
+ else if(other.get('_type') === 'path')
+ strategy = _testCirclePathCollision;
+ else if(other.get('aura1_square'))
+ strategy = _testCircleRectCollision;
+ else
+ strategy = _testCirclesCollision;
+ return strategy(token, other, waypoint);
+ }
+
+ /**
+ * Tests if a token overlaps another token or path, using a strategy
+ * appropriate for the shapes of the objects.
+ * @private
+ * @param {graphic} token
+ * @param {(graphic|path)} other
+ * @param {int} inset
+ * @return {boolean}
+ */
+ function _testOverlap(token, other, inset) {
+ let strategy;
+ if(token.get('aura1_square'))
+ if(other.get('_type') === 'path')
+ strategy = _isOverlappingRectPath;
+ else if(other.get('aura1_square'))
+ strategy = _isOverlappingRectRect;
+ else
+ strategy = _isOverlappingCircleRect;
+ else
+ if(other.get('_type') === 'path')
+ strategy = _isOverlappingCirclePath;
+ else if(other.get('aura1_square'))
+ strategy = _isOverlappingCircleRect;
+ else
+ strategy = _isOverlappingCircleCircle;
+ return strategy(token, other, inset);
+ }
+
+ /**
+ * Tests for an in-movement collisions between a rectangular token
+ * and a circular token.
+ * @param {Graphic} token
+ * @param {Graphic} other
+ * @param {waypoint} waypoint
+ * @return {number}
+ * The distance along the waypoint at which the collision happens,
+ * or undefined if there is no collision.
+ */
+ function _testRectCircleCollision(token, other, waypoint) {
+ // Reduce the problem to a circle-rect test in the opposite direction.
+ let start = waypoint[0];
+ start[2] = 1;
+ let end = waypoint[1];
+ end[2] = 1;
+
+ let tokenPt = _getPt(token);
+ tokenPt[2] = 1;
+ let otherPt = _getPt(other);
+ otherPt[2] = 1;
+
+ let u = VecMath.sub(end, start);
+ let uReverse = VecMath.scale(u, -1);
+
+ let poly = _tokenToRect(token);
+ let offset = VecMath.sub(start, tokenPt);
+ poly = poly.transform(MatrixMath.translate(offset));
+
+ let reverseWaypoint = [otherPt, VecMath.add(otherPt, uReverse)];
+ return _testCirclePolyCollision(other, poly, reverseWaypoint);
+ }
+
+ /**
+ * Tests for an in-movement collision between a rectangular token and a
+ * path. The path is treated as a polygon if its fill is not transparent.
+ * @param {Graphic} token
+ * @param {Path} path
+ * @param {waypoint} waypoint
+ * @return {number} The minimum collision distance.
+ */
+ function _testRectPathCollision(token, path, waypoint) {
+ let shape;
+ if(path.get('fill') === 'transparent')
+ shape = new PathMath.Path(path);
+ else
+ shape = new PathMath.Polygon(path);
+ return _testRectPolyCollision(token, shape, waypoint);
+ }
+
+ /**
+ * Tests for an in-movement collision between two polygons.
+ * @private
+ * @param {graphic} token
+ * @param {(PathMath.Polygon|PathMath.Path)} poly
+ * @param {waypoint} waypoint
+ * @return {number}
+ */
+ function _testRectPolyCollision(token, poly, waypoint) {
+ let start = waypoint[0];
+ start[2] = 1;
+ let end = waypoint[1];
+ end[2] = 1;
+
+ let u = VecMath.sub(end, start);
+ let uLen = VecMath.length(u);
+ let uAngle = Math.atan2(u[1], u[0]);
+
+ // Get the rectangle for the token's final position.
+ let rect = _tokenToRect(token, 1); // Inset by 1 to avoid edges.
+ let rectPt = _getPt(token);
+ rectPt[2] = 1;
+ let rectOffset = VecMath.sub(start, rectPt);
+
+ // Get the rectangle for the waypoint's start.
+ let startRect = rect.transform(MatrixMath.translate(rectOffset));
+ let startBox = startRect.getBoundingBox();
+
+ // Get the rectangle for the waypoint's end.
+ let endRect = startRect.transform(MatrixMath.translate(u));
+ let endBox = endRect.getBoundingBox();
+
+ // Quit early if the polygon's bounding box does not intersect the
+ // union of the start and end rects' bounding boxes.
+ let moveBox = PathMath.BoundingBox.add(startBox, endBox);
+ if(!moveBox.intersects(poly.getBoundingBox()))
+ return undefined;
+
+ // Quit early if the polygons intersect.
+ if(startRect.intersects(poly))
+ return undefined;
+
+ // Transform the system so that the token's start rect is at the origin and
+ // u points up.
+ let rotation = Math.PI/2 - uAngle;
+ let m = MatrixMath.multiply(
+ MatrixMath.rotate(rotation),
+ MatrixMath.translate(VecMath.sub([0,0,1], start))
+ );
+ let mPoly = poly.transform(m);
+ let mRect = startRect.transform(m);
+
+ // Get the sets of clipped segments to test collisions between.
+ let mRectSegs = _testRectPolyCollision_clipRectSegs(mRect, moveBox);
+ let mPolySegs = _testRectPolyCollision_clipPolySegs(mPoly, mRect);
+ let minDist = _testRectPolyCollision_getMinDist(mPolySegs, mRectSegs);
+ if(minDist === Infinity || minDist > uLen)
+ return undefined;
+ return minDist;
+ }
+
+ // Clip the lower segments from the rect.
+ function _testRectPolyCollision_clipRectSegs(mRect, moveBox) {
+ return _.chain(mRect.toSegments())
+ .filter(seg => {
+ let u = VecMath.sub(seg[1], seg[0]);
+ let testPt = [seg[0][0], moveBox.height*2, 1];
+ let v = VecMath.sub(testPt, seg[0]);
+ let cross = VecMath.cross(u, v);
+
+ // Keep the segment if the test point is on its "left" side.
+ return cross[2] < 0;
+ })
+ .map(seg => {
+ let p = seg[0];
+ let q = seg[1];
+
+ // let p be the leftmost point.
+ if(p[0] > q[0]) {
+ let swap = q;
+ q = p;
+ p = swap;
+ }
+
+ // Get the segment's line equation data.
+ let dx = q[0] - p[0];
+ let dy = q[1] - p[1];
+ let m = dy/dx;
+ let b = p[1] - m*p[0];
+ let newSeg = [p, q];
+ newSeg.m = m;
+ newSeg.b = b;
+ return newSeg;
+ })
+ .value();
+ }
+
+ // Clip segments from the polygon that are outside the collision space.
+ function _testRectPolyCollision_clipPolySegs(mPoly, mRect) {
+ let mRectBox = mRect.getBoundingBox();
+ let left = mRectBox.left;
+ let right = left + mRectBox.width;
+
+ return _.chain(mPoly.toSegments())
+ .map(seg => {
+ let p = seg[0];
+ let q = seg[1];
+
+ // Keep vertical segments that are within the collision space.
+ if(p[0] === q[0] && p[0] >= left && p[0] <= right) {
+ seg.m = undefined;
+ seg.b = undefined;
+ return seg;
+ }
+
+ // let p be the leftmost point.
+ if(p[0] > q[0]) {
+ let swap = q;
+ q = p;
+ p = swap;
+ }
+
+ // Clip segments that are entirely outside the collision space.
+ if(p[0] < left && q[0] < left)
+ return undefined;
+ if(p[0] > right && q[0] > right)
+ return undefined;
+ if(p[1] < 0 && q[1] < 0)
+ return undefined;
+
+ // Clip intersections with the left and right borders
+ // of the collision space.
+ let dx = q[0] - p[0];
+ let dy = q[1] - p[1];
+ let m = dy/dx;
+ let b = p[1] - m*p[0];
+ if(p[0] < left)
+ p = [left, m*left + b, 1];
+ if(q[0] > right)
+ q = [right, m*right + b, 1];
+
+ let clippedSeg = [p, q];
+ clippedSeg.m = m;
+ clippedSeg.b = b;
+ return clippedSeg;
+ })
+ .compact()
+ .value();
+ }
+
+ // Using the power of linear algebra, find the minimum distance to any of the
+ // polygon's segments.
+ function _testRectPolyCollision_getMinDist(mPolySegs, mRectSegs) {
+ return _.chain(mPolySegs)
+ .map(polySeg => {
+ return _.chain(mRectSegs)
+ .map(rectSeg => {
+ let fofX = x => {
+ return polySeg.m * x + polySeg.b;
+ };
+ let gofX = x => {
+ return rectSeg.m * x + rectSeg.b;
+ };
+ let hofX = x => {
+ return fofX(x) - gofX(x);
+ };
+ let left = rectSeg[0][0];
+ let right = rectSeg[1][0];
+
+ let p = polySeg[0];
+ let q = polySeg[1];
+
+ // Skip if this polySeg is not directly above rectSeg.
+ if(p[0] < left && q[0] < left)
+ return undefined;
+ if(p[0] > right && q[0] > right)
+ return undefined;
+
+ // Clip the intersections on the left and right sides of rectSeg.
+ if(p[0] < left)
+ p = [left, fofX(left), 1];
+ if(q[0] > right)
+ q = [right, fofX(right), 1];
+
+ // Return the minimum distance among the clipped polySeg's endpoints.
+ let dist = Math.min(hofX(p[0]), hofX(q[0]));
+ if(dist > 0)
+ return dist;
+ return undefined;
+ })
+ .compact()
+ .min()
+ .value();
+ })
+ .compact()
+ .min()
+ .value();
+ }
+
+ /**
+ * Tests for a collision between two rectangular tokens and returns
+ * the shortest distance to the collision.
+ * @param {Graphic} token
+ * @param {Graphic} other
+ * @param {waypoint} waypoint
+ * @return {number}
+ * The distance along the waypoint at which the collision happens,
+ * or undefined if there is no collision.
+ */
+ function _testRectsCollision(token, other, waypoint) {
+ let poly = _tokenToRect(other);
+ return _testRectPolyCollision(token, poly, waypoint);
+ }
+
+ /**
+ * Gets the circle bounding a token.
+ * @param {Graphic} token
+ * @param {number} inset
+ * @return {PathMath.Circle}
+ */
+ function _tokenToCircle(token, inset) {
+ inset = inset || 0;
+ let x = token.get('left');
+ let y = token.get('top');
+ let r = token.get('width')/2 - inset;
+ return new PathMath.Circle([x, y, 1], r);
+ }
+
+ /**
+ * Gets the rectangule bounding a token.
+ * @private
+ * @param {Graphic} token
+ * @param {number} inset
+ * @return {PathMath.Polygon}
+ */
+ function _tokenToRect(token, inset) {
+ inset = inset || 0;
+
+ let width = token.get('width') - inset;
+ let height = token.get('height') - inset;
+ let pt = _getPt(token);
+ let angle = token.get('rotation')*Math.PI/180;
+
+ let m = MatrixMath.multiply(
+ MatrixMath.translate(pt),
+ MatrixMath.rotate(angle)
+ );
+ return new PathMath.Polygon([
+ MatrixMath.multiply(m, [-width/2, -height/2, 1]),
+ MatrixMath.multiply(m, [width/2, -height/2, 1]),
+ MatrixMath.multiply(m, [width/2, height/2, 1]),
+ MatrixMath.multiply(m, [-width/2, height/2, 1])
+ ]);
+ }
+
+
+ // The exposed API.
+ return class TokenCollisions {
+
+ /**
+ * Returns the list of other tokens that some token collided with during
+ * its last movement.
+ * The tokens are sorted in the order they are collided with.
+ * @param {Graphic} token
+ * @param {(Graphic|Path|PathMath.Polygon)[]} others
+ * @param {CollisionOptions} [options]
+ * @return {(Graphic[]|Collisions[])}
+ */
+ static getCollisions(token, others, options) {
+ return _.chain(_getWaypoints(token))
+ .map(waypoint => {
+ return _getCollisionsInWaypoint(token, others, waypoint, options);
+ })
+ .flatten()
+ .value();
+ }
+
+ /**
+ * Returns the first token, from some list of tokens, that a token has
+ * collided with during its last movement, or undfined if there was
+ * no collision.
+ * @param {Graphic} token
+ * @param {(Graphic|Path|PathMath.Polygon)[]} others
+ * @param {CollisionOptions} [options]
+ * @return {(Graphic|Collision)}
+ */
+ static getFirstCollision(token, others, options) {
+ return TokenCollisions.getCollisions(token, others, options)[0];
+ }
+
+ /**
+ * Checks if a non-moving token is currently overlapping another token.
+ * This supports circular and rectangular tokens.
+ * Tokens are considered to be rectangular if their aura1 is a square.
+ * @param {Graphic} token
+ * @param {Graphic} other
+ * @param {boolean} [collideOnEdge=false]
+ * Whether tokens should count as overlapping even if they are only
+ * touching on the very edge.
+ * @return {Boolean}
+ */
+ static isOverlapping(token, other, collideOnEdge) {
+ if(token.get('_id') === other.get('_id'))
+ return false;
+
+ // Inset by 1 pixel if we don't want to collide on edges.
+ let inset = 1;
+ if(collideOnEdge)
+ inset = 0;
+
+ return _testOverlap(token, other, inset);
+ }
+ };
+})();
diff --git a/Token Collisions/script.json b/Token Collisions/script.json
index c78ebeb813..97e1c23f2e 100644
--- a/Token Collisions/script.json
+++ b/Token Collisions/script.json
@@ -1,8 +1,8 @@
{
"name": "Token Collisions",
"script": "TokenCollisions.js",
- "version": "1.5",
- "previousversions": ["1.1", "1.2", "1.3", "1.4"],
+ "version": "1.6",
+ "previousversions": ["1.1", "1.2", "1.3", "1.4", "1.5"],
"description": "# Token Collisions\r\r_v1.5 Updates:_\r* Collisions with Paths and arbitrary polygons are now supported. Paths with non-transparent fills are treated as polygons.\r* All collisions are now entirely pixel-perfect.\r\r_v1.4 Updates:_\r* getCollisions and getFirstCollision now accept an options object parameter. See the CollisionOptions typedef jsdoc for supported properties.\r\r_v1.3 Updates:_\r* Supports circle-to-rectangle token collisions.\r* Added isOverlapping() function.\r\rThis script provides a small library for checking for collisions between\rtokens. It provides no functionality by itself, but it is used by other\rscripts such as ```It's A Trap``` and ```World Map Discovery```.\r\r## Rectangular tokens\r\rBy default, all tokens are assumed to be circular with a diameter equal to their\rwidth. You can set a token to be rectangular for this script by setting its\rAura1 to a square.\r\r## API Documentation:\r\rThe following functions are exposed by the ```TokenCollisions``` object:\r\r```\r/**\r * Returns the list of other tokens that some token collided with during\r * its last movement.\r * The tokens are sorted in the order they are collided with.\r * @param {Graphic} token\r * @param {(Graphic|Path|PathMath.Polygon)[]} others\r * @return {Graphic[]}\r */\rfunction getCollisions(token, others)\r```\r\r```\r/**\r * Returns the first token, from some list of tokens, that a token has\r * collided with during its last movement, or undfined if there was\r * no collision.\r * @param {Graphic} token\r * @param {(Graphic|Path|PathMath.Polygon)[]} others\r * @return {Graphic}\r */\rfunction getFirstCollision(token, others)\r```\r\r```\r/**\r * Checks if a non-moving token is currently overlapping another token.\r * This supports circular and rectangular tokens.\r * Tokens are considered to be rectangular if their aura1 is a square.\r * @param {Graphic} token\r * @param {Graphic} other\r * @param {boolean} [collideOnEdge=false]\r * Whether tokens should count as overlapping even if they are only\r * touching on the very edge.\r * @return {Boolean}\r */\rfunction isOverlapping(token, other, collideOnEdge)\r```\r\r## Help\r\rIf you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature, please\rpost to the script's thread in the API forums or shoot me a PM:\rhttps://app.roll20.net/users/46544/stephen-l\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, and maintaining my API scripts, consider buying one of my art packs from the Roll20 marketplace (https://marketplace.roll20.net/browse/search/?keywords=&sortby=newest&type=all&genre=all&author=Stephen%20Lindberg)\ror, simply leave a thank you note in the script's thread on the Roll20 forums.\rEither is greatly appreciated! Happy gaming!\r",
"authors": "Stephen Lindberg",
"roll20userid": 46544,