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.
` + + `
${json}
`; + + let menu = new MarchingOrder.utils.Menu('Export Marching Order', content); + menu.show(player); + return json; + } + + /** + * Gets the script's configured options. + * @return {Object} + */ + static getOptions() { + let scriptState = MarchingOrder.State.getState(); + if(!scriptState.options) + scriptState.options = {}; + return scriptState.options; + } + + /** + * Returns this module's object for the Roll20 API state. + * @return {Object} + */ + static getState() { + if(!state.marchingOrder) + state.marchingOrder = {}; + + _.defaults(state.marchingOrder, { + savedFormations: {} + }); + + return state.marchingOrder; + } + + /** + * Imports the state for this script from JSON. + * @param {Player} player + * @param {string} json + */ + static importState(player, json) { + let scriptState = MarchingOrder.State.getState(); + _.extend(scriptState, JSON.parse(json)); + + MarchingOrder.Wizard.show(player); + } + + /** + * Gets the JSON string for this script's state. + * @return {string} + */ + static jsonifyState() { + let scriptState = MarchingOrder.State.getState(); + return JSON.stringify(scriptState); + } + }; +})(); + +(() => { + 'use strict'; + + MarchingOrder.Wizard = class { + + /** + * Create an instance of the main menu. + * @param {Player} player + * @return {MarchingOrder.utils.Menu} + */ + static getMainMenu(player) { + let playerId = player.get('_id'); + const Commands = MarchingOrder.Commands; + const Menu = MarchingOrder.utils.Menu; + + // Menu options + // Follow (define a new marching formation) + let actionsHtml = '
[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 += '
' + + '[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); + } + + /** + * 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; + } + }; +})(); + +/** + * utils package + */ +(() => { + 'use strict'; + + /** + * Cookbook.getCleanImgsrc + * https://wiki.roll20.net/API:Cookbook#getCleanImgsrc + */ + function getCleanImgsrc(imgsrc) { + let parts = imgsrc.match(/(.*\/(images|marketplace)\/.*)(thumb|med|original|max)(.*)$/); + if(parts) + 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. ' + + 'Offending URL: ' + imgsrc); + } + + MarchingOrder.utils = { + getCleanImgsrc + }; +})(); + +(() => { + 'use strict'; + + const FROM_NAME = 'MarchingOrder'; + + /** + * This module provides chat-related functions. + */ + MarchingOrder.utils.Chat = class { + /** + * Displays a message in the chat visible to all players. + * @param {string} message + */ + static broadcast(message) { + sendChat(FROM_NAME, message); + } + + /** + * Notify GMs about an error and logs its stack trace. + * @param {Error} err + */ + static error(err) { + log(`MarchingOrder ERROR: ${err.message}`); + log(err.stack); + MarchingOrder.utils.Chat.whisperGM( + `ERROR: ${err.message} --- See API console log for details.`); + } + + /** + * Fixes the 'who' string from a Message so that it can be reused as a + * whisper target using Roll20's sendChat function. + * @param {string} who The player name taken from the 'who' property of a + * chat:message event. + * @return {string} + */ + static fixWho(srcWho) { + 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. + * @param {string} msg The whispered message. + */ + static whisper(player, msg) { + let name = player.get('_displayname'); + let cleanName = MarchingOrder.utils.Chat.fixWho(name); + sendChat(FROM_NAME, '/w "' + cleanName + '" ' + msg); + } + + /** + * Whispers a message to the GM. + * @param {string} message + */ + static whisperGM(message) { + sendChat(FROM_NAME, '/w gm ' + message); + } + }; +})(); + +(() => { + 'use strict'; + + /** + * An in-chat menu. + */ + MarchingOrder.utils.Menu = class { + /** + * The HTML for this menu. + */ + get html() { + let html = '
'; + html += '
' + this._header + '
'; + html += '
' + this._content + '
'; + html += '
'; + return html; + } + + constructor(header, content) { + this._header = header; + this._content = content; + } + + /** + * Show the menu to a player. + */ + show(player) { + MarchingOrder.utils.Chat.whisper(player, this.html); + } + }; +})(); diff --git a/MarchingOrder/Gruntfile.js b/MarchingOrder/Gruntfile.js index e2ef071de2..0d9c55a229 100644 --- a/MarchingOrder/Gruntfile.js +++ b/MarchingOrder/Gruntfile.js @@ -29,8 +29,9 @@ module.exports = function(grunt) { freeze: true, globals: { // Symbols defined by API scripts - CustomStatusMarkers: true, MarchingOrder: true, + TokenCollisions: true, + VecMath: true, // Symbols defined by Roll20 _: false, @@ -64,7 +65,7 @@ module.exports = function(grunt) { options: { replacements: [ { - pattern: 'SCRIPT_VERSION', + pattern: /SCRIPT_VERSION/g, replacement: '<%= pkg.version %>' }, diff --git a/MarchingOrder/README.md b/MarchingOrder/README.md index 8f7e4dac3d..646fc152b1 100644 --- a/MarchingOrder/README.md +++ b/MarchingOrder/README.md @@ -1,72 +1,108 @@ # Marching Order -_v2.4 Updates_ -* Code refactored into modules. +_v3.0 Updates_ +* The script has been reworked entirely. Marching orders now work based upon formations formed when you tell a group of tokens to follow a leader. +* You can now save multiple formations and load them up to be used later. This script allows you to select tokens and tell them to follow each other -in some specified marching order. +in some specified marching formation. Marching orders are defined by formations, +where there is a leader token and one or more follower tokens. When you set +tokens to follow a leader, it will record their position relative to the +leader in that formation, and they'll maintain that position as the leader +moves. This formation is maintained even when the leader travels around corners. -### To set one token to follow another +## Chat Menu -Select the token that will be the follower. -From the menu, click the 'Follow' button. You will then be prompted to click -the token that will be the leader. +When the script is installed, it will create a macro called +'MarchingOrderMenu'. This macro will display a menu in the chat that provides +the user interface for the script. -You can use this method consecutively for pairs of leader/follower -tokens to specify a chain of tokens to be in some marching order. +### New formation -### To specify several tokens making up a marching order +To create a new marching order formation, select a group of tokens, with one +of them having either the black-flag or flying-flag status marker active. That +token will be the leader of the formation. (See Moving in Formation below) -Arrange the tokens in order from west to east, east to west, south to north, -or north to south. +Then click the New Formation button the chat menu. It will ask you to enter +which direction the formation is facing at the time you've created it, and it +will ask you to give a name for the new formation. This marching formation will +be applied to the selected tokens and it will also be saved for future use +(See Saved Formations). -Select all the tokens that will be in the marching order. +Be advised of the following gotchas: +* You must have one of the selected tokens have either the 'black-flag' or 'flying flag' status marker. This is used to designate the leader of the formation. +* Each new formation must have a unique name. -In the menu, click 'North', 'East', South', or 'West'. +### Stop Following -For 'North', the northmost token will be the leader in the marching order -and the southmost token will be the caboose. The same pattern follows for -the other cardinal directions. +To stop all the active marching formations, click the Stop All Following button +in the chat menu. -### To make tokens stop following each other +### Saved Formations -Just manually drag-and-drop the token out of the marching order. -They will step out of line in the marching order, but the rest of the -marching order will be unaffected. If a token was following the token -that stepped out of line, they will instead follow the token that the token -which stepped out of line was following. +Under this section of the chat menu, you can view the formations you have +previously created. The formations each displayed in a boxed area with their name, +a preview of the formation, and some buttons to either use the formation or +delete it. The previews display the marching orders proceeding westward. -To stop all tokens from following each other, click the Stop All Following -button in the menu. +#### Saved Formations -> Use -### Default marching order +Click this button to use the previously saved marching formation. It will be +applied to the relevant tokens on the page that currently has the +players ribbon. -From the menu, you can also set a reusable default marching order and apply it -to the current players' page. +Be advised of the following gotchas: +* All of the tokens in the formation must be on the current page. If a token is missing, the script will yell at you for it, but the rest of the tokens will be allowed to move in formation. +* There can't be duplicates of any tokens in the formation on the current page. -To set a default marching order, select the leader token in an active -marching order and click the Set Default button from the menu. Each token -in the marching order must represent a character. +#### Saved Formations -> Delete -To apply the saved default marching order to the current player ribbon page, -click the Use Default button from the menu. If a character from the default -order is missing from the current page, they will be skipped, but the rest -of the marching order will be applied. +Click this button to delete the previously saved marching formation. + +### Clear Script State + +If you'd like to start afresh with a whole blank state for the Marching Order +script, you can click the Clear Script State button to reset the script +to its factory settings. + +## Moving in Formation + +When a marching formation is applied, all the tokens relevant to the formation +will begin following their leader in that formation. When the leader moves, +the other tokens in the formation will move along with them, according to +their position in the formation. + +## Stepping out of Formation + +You can remove a token from an active formation at any time by just moving it +manually. That token will no longer move along with the other tokens in that +formation. + +## But I liked the old version of this script! + +You can still use the old version of this script by reverting back to +version 2.4. However, do so with the understanding that I won't be providing +any further technical support on versions prior to 3.0. ## Help -If you experience any issues while using this script or the trap themes, +My scripts are provided 'as-is', without warranty of any kind, expressed or implied. + +That said, if you experience any issues while using this script, need help using it, or if you have a neat suggestion for a new feature, please shoot me a PM: https://app.roll20.net/users/46544/stephen-l -or create a help thread on the Roll20 API forum + +When messaging me about an issue, please be sure to include any error messages that +appear in your API Console Log, any configurations you've got set up for the +script in the VTT, and any options you've got set up for the script on your +game's API Scripts page. The more information you provide me, the better the +chances I'll be able to help. ## Show Support If you would like to show your appreciation and support for the work I do in writing, -updating, maintaining, and providing tech support my API scripts, please consider buying one of my art packs from the Roll20 marketplace: +updating, maintaining, and providing tech support my API scripts, +please consider buying one of my art packs from the Roll20 marketplace: https://marketplace.roll20.net/browse/search?category=itemtype:Art&author=Stephen%20Lindberg|Stephen%20L - -or, simply leave a thank you note in the script's thread on the Roll20 forums. -Either is greatly appreciated! Happy gaming! diff --git a/MarchingOrder/demo.gif b/MarchingOrder/demo.gif index fe0173a0f6..29db27b712 100644 Binary files a/MarchingOrder/demo.gif and b/MarchingOrder/demo.gif differ diff --git a/MarchingOrder/script.json b/MarchingOrder/script.json index 5b5feb585a..fbd8b66289 100644 --- a/MarchingOrder/script.json +++ b/MarchingOrder/script.json @@ -1,13 +1,16 @@ { "name": "Marching Order", "script": "marchingOrder.js", - "version": "2.4", - "previousversions": ["2.0", "2.1", "2.2", "2.3"], - "description": "# Marching Order\r\r_v2.4 Updates_\r* Code refactored into modules.\r\rThis script allows you to select tokens and tell them to follow each other\rin some specified marching order.\r\r### To set one token to follow another\r\rSelect the token that will be the follower.\rFrom the menu, click the 'Follow' button. You will then be prompted to click\rthe token that will be the leader.\r\rYou can use this method consecutively for pairs of leader/follower\rtokens to specify a chain of tokens to be in some marching order.\r\r### To specify several tokens making up a marching order\r\rArrange the tokens in order from west to east, east to west, south to north,\ror north to south.\r\rSelect all the tokens that will be in the marching order.\r\rIn the menu, click 'North', 'East', South', or 'West'.\r\rFor 'North', the northmost token will be the leader in the marching order\rand the southmost token will be the caboose. The same pattern follows for\rthe other cardinal directions.\r\r### To make tokens stop following each other\r\rJust manually drag-and-drop the token out of the marching order.\rThey will step out of line in the marching order, but the rest of the\rmarching order will be unaffected. If a token was following the token\rthat stepped out of line, they will instead follow the token that the token\rwhich stepped out of line was following.\r\rTo stop all tokens from following each other, click the Stop All Following\rbutton in the menu.\r\r### Default marching order\r\rFrom the menu, you can also set a reusable default marching order and apply it\rto the current players' page.\r\rTo set a default marching order, select the leader token in an active\rmarching order and click the Set Default button from the menu. Each token\rin the marching order must represent a character.\r\rTo apply the saved default marching order to the current player ribbon page,\rclick the Use Default button from the menu. If a character from the default\rorder is missing from the current page, they will be skipped, but the rest\rof the marching order will be applied.\r\r## Help\r\rIf you experience any issues while using this script or the trap themes,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/stephen-l\ror create a help thread on the Roll20 API forum\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts, please consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/search?category=itemtype:Art&author=Stephen%20Lindberg|Stephen%20L\r\ror, simply leave a thank you note in the script's thread on the Roll20 forums.\rEither is greatly appreciated! Happy gaming!\r", + "version": "3.0", + "previousversions": ["2.0", "2.1", "2.2", "2.3", "2.4"], + "description": "# Marching Order\r\r_v3.0 Updates_\r* The script has been reworked entirely. Marching orders now work based upon formations formed when you tell a group of tokens to follow a leader.\r* You can now save multiple formations and load them up to be used later.\r\rThis script allows you to select tokens and tell them to follow each other\rin some specified marching formation. Marching orders are defined by formations,\rwhere there is a leader token and one or more follower tokens. When you set\rtokens to follow a leader, it will record their position relative to the\rleader in that formation, and they'll maintain that position as the leader\rmoves. This formation is maintained even when the leader travels around corners.\r\r## Chat Menu\r\rWhen the script is installed, it will create a macro called\r'MarchingOrderMenu'. This macro will display a menu in the chat that provides\rthe user interface for the script.\r\r### New formation\r\rTo create a new marching order formation, select a group of tokens, with one\rof them having either the black-flag or flying-flag status marker active. That\rtoken will be the leader of the formation. (See Moving in Formation below)\r\rThen click the New Formation button the chat menu. It will ask you to enter\rwhich direction the formation is facing at the time you've created it, and it\rwill ask you to give a name for the new formation. This marching formation will\rbe applied to the selected tokens and it will also be saved for future use\r(See Saved Formations).\r\rBe advised of the following gotchas:\r* You must have one of the selected tokens have either the 'black-flag' or 'flying flag' status marker. This is used to designate the leader of the formation.\r* Each new formation must have a unique name.\r\r### Stop Following\r\rTo stop all the active marching formations, click the Stop All Following button\rin the chat menu.\r\r### Saved Formations\r\rUnder this section of the chat menu, you can view the formations you have\rpreviously created. The formations each displayed in a boxed area with their name,\ra preview of the formation, and some buttons to either use the formation or\rdelete it. The previews display the marching orders proceeding westward.\r\r#### Saved Formations -> Use\r\rClick this button to use the previously saved marching formation. It will be\rapplied to the relevant tokens on the page that currently has the\rplayers ribbon.\r\rBe advised of the following gotchas:\r* All of the tokens in the formation must be on the current page. If a token is missing, the script will yell at you for it, but the rest of the tokens will be allowed to move in formation.\r* There can't be duplicates of any tokens in the formation on the current page.\r\r#### Saved Formations -> Delete\r\rClick this button to delete the previously saved marching formation.\r\r### Clear Script State\r\rIf you'd like to start afresh with a whole blank state for the Marching Order\rscript, you can click the Clear Script State button to reset the script\rto its factory settings.\r\r## Moving in Formation\r\rWhen a marching formation is applied, all the tokens relevant to the formation\rwill begin following their leader in that formation. When the leader moves,\rthe other tokens in the formation will move along with them, according to\rtheir position in the formation.\r\r## Stepping out of Formation\r\rYou can remove a token from an active formation at any time by just moving it\rmanually. That token will no longer move along with the other tokens in that\rformation.\r\r## But I liked the old version of this script!\r\rYou can still use the old version of this script by reverting back to\rversion 2.4. However, do so with the understanding that I won't be providing\rany further technical support on versions prior to 3.0.\r\r## Help\r\rMy scripts are provided 'as-is', without warranty of any kind, expressed or implied.\r\rThat said, if you experience any issues while using this script,\rneed help using it, or if you have a neat suggestion for a new feature,\rplease shoot me a PM:\rhttps://app.roll20.net/users/46544/stephen-l\r\rWhen messaging me about an issue, please be sure to include any error messages that\rappear in your API Console Log, any configurations you've got set up for the\rscript in the VTT, and any options you've got set up for the script on your\rgame's API Scripts page. The more information you provide me, the better the\rchances I'll be able to help.\r\r## Show Support\r\rIf you would like to show your appreciation and support for the work I do in writing,\rupdating, maintaining, and providing tech support my API scripts,\rplease consider buying one of my art packs from the Roll20 marketplace:\r\rhttps://marketplace.roll20.net/browse/search?category=itemtype:Art&author=Stephen%20Lindberg|Stephen%20L\r", "authors": "Stephen Lindberg", "roll20userid": 46544, "useroptions": [], - "dependencies": {}, + "dependencies": [ + "Vector Math", + "Token Collisions" + ], "modifies": { "left": "read, write", "rotation": "read, write", diff --git a/MarchingOrder/src/Commands.js b/MarchingOrder/src/Commands.js index 850fb1f7e5..a2d4f85c85 100644 --- a/MarchingOrder/src/Commands.js +++ b/MarchingOrder/src/Commands.js @@ -5,75 +5,111 @@ 'use strict'; const MENU_CMD = '!showMarchingOrderMenu'; - const FOLLOW_CMD = '!marchingOrderFollow'; + const NEW_FORMATION_CMD = '!marchingOrderNewFormation'; const STOP_ALL_CMD = '!marchingOrderStopAll'; - const DEFAULT_USE_CMD = '!marchingOrderUseDefault'; - const DEFAULT_SET_CMD = '!marchingOrderSetDefault'; + const USE_FORMATION_CMD = '!marchingOrderUseFormation'; + const DELETE_FORMATION_CMD = '!marchingOrderDeleteFormation'; + const CLEAR_STATE_CMD = '!marchingOrderClearState'; /** - * Extracts the selected graphics from a chat message. - * @param {ChatMessage} msg - * @return {Graphic[]} + * Process an API command to clear the script's state. */ - function _getGraphicsFromMsg(msg) { - var result = []; + 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."); - var selected = msg.selected; - if(selected) { - _.each(selected, s => { - let graphic = getObj('graphic', s._id); - if(graphic) - result.push(graphic); - }); + let isSure = (argv[1] === 'yes'); + if (isSure) { + MarchingOrder.State.clearState(); + MarchingOrder.Wizard.showMainMenu(player); + } } - return result; + else + MarchingOrder.utils.Chat.tattle(player, 'Clear State'); } /** - * Process an API command to have tokens follow each other. + * Delete a saved marching formation. */ - function _cmdFollow(msg) { - let argv = msg.content.split(' '); - let dirMatch = argv[1].match(/(north|south|east|west)/); - if(dirMatch) { - let selected = _getGraphicsFromMsg(msg); - MarchingOrder.followAllDirection(selected, dirMatch[0]); - } - else { - let follower = getObj('graphic', argv[1]); - let leader = getObj('graphic', argv[2]); - if(follower && leader) - MarchingOrder.followAllToken(follower, leader); - else - throw new Error(`Invalid arguments given for FOLLOW_CMD: ${argv.slice(1)}`); + 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 set the default marching order. + * Process an API command to have tokens follow each other. */ - function _cmdSetDefaultMarchingOrder(msg) { - let argv = msg.content.split(' '); - let leader = getObj('graphic', argv[1]); - if (leader) { - MarchingOrder.Config.setDefaultMarchingOrder(leader); - menu(msg); + 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 - throw new Error(`Leader token not found for DEFAULT_SET_CMD: ${argv.slice(1)}`); + MarchingOrder.utils.tattle(player, 'Delete Saved Formation'); } - function _cmdUnfollowAll(msg) { - _.noop(msg); - MarchingOrder.unfollowAll(); + /** + * 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'); } /** - * Process an aPI command to use the default marching order. + * Stops all marching orders currently in use. */ - function _cmdUseDefaultMarchingOrder(msg) { - _.noop(msg); - MarchingOrder.useDefaultMarchingOrder(); + function _cmdStopAll(msg) { + let player = getObj('player', msg.playerid); + if (playerIsGM(msg.playerid)) + MarchingOrder.unfollowAll(); + else + MarchingOrder.utils.Chat.tattle(player, 'Stop All Formations'); } /** @@ -81,7 +117,7 @@ */ function menu(msg) { let player = getObj('player', msg.playerid); - MarchingOrder.Wizard.show(player); + MarchingOrder.Wizard.showMainMenu(player); } // Event handler for the script's API chat commands. @@ -90,14 +126,16 @@ try { if(argv[0] === MENU_CMD) menu(msg); - else if (argv[0] === DEFAULT_SET_CMD) - _cmdSetDefaultMarchingOrder(msg); - else if (argv[0] === DEFAULT_USE_CMD) - _cmdUseDefaultMarchingOrder(msg); - else if (argv[0] === FOLLOW_CMD) - _cmdFollow(msg); + else if (argv[0] === NEW_FORMATION_CMD) + _cmdNewFormation(msg); else if (argv[0] === STOP_ALL_CMD) - _cmdUnfollowAll(msg); + _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); @@ -108,10 +146,11 @@ * Expose the command constants for use in other modules. */ MarchingOrder.Commands = { - DEFAULT_SET_CMD, - DEFAULT_USE_CMD, - FOLLOW_CMD, + CLEAR_STATE_CMD, + DELETE_FORMATION_CMD, MENU_CMD, - STOP_ALL_CMD + NEW_FORMATION_CMD, + STOP_ALL_CMD, + USE_FORMATION_CMD }; })(); diff --git a/MarchingOrder/src/State.js b/MarchingOrder/src/State.js index 1efce16882..b4b61898fd 100644 --- a/MarchingOrder/src/State.js +++ b/MarchingOrder/src/State.js @@ -6,6 +6,14 @@ */ 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 @@ -38,9 +46,12 @@ */ static getState() { if(!state.marchingOrder) - state.marchingOrder = { - defaultOrder: [] - }; + state.marchingOrder = {}; + + _.defaults(state.marchingOrder, { + savedFormations: {} + }); + return state.marchingOrder; } diff --git a/MarchingOrder/src/Wizard.js b/MarchingOrder/src/Wizard.js index a24eae1c28..34dd089df6 100644 --- a/MarchingOrder/src/Wizard.js +++ b/MarchingOrder/src/Wizard.js @@ -10,50 +10,157 @@ */ static getMainMenu(player) { let playerId = player.get('_id'); - let moState = MarchingOrder.State.getState(); const Commands = MarchingOrder.Commands; const Menu = MarchingOrder.utils.Menu; // Menu options - let actionsHtml = '
[Follow](' + Commands.FOLLOW_CMD + ' @{selected|token_id} @{target|token_id})
'; + // Follow (define a new marching formation) + 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 += '
March in order:
'; - actionsHtml += '
'; - actionsHtml += ''; - actionsHtml += ''; - actionsHtml += ''; - actionsHtml += '
[North](' + Commands.FOLLOW_CMD + ' north)
[West](' + Commands.FOLLOW_CMD + ' west)[East](' + Commands.FOLLOW_CMD + ' east)
[South](' + Commands.FOLLOW_CMD + ' south)
'; - // Stop all following - actionsHtml += '
[Stop All Following](' + Commands.STOP_ALL_CMD + ')
'; - - // Default marching order - actionsHtml += '
Default Marching Order:
'; - if(moState.defaultOrder.length > 0) { - actionsHtml += '
'; - _.each(moState.defaultOrder, (item, index) => { - actionsHtml += ''; - if(index !== 0) - actionsHtml += ' ◀ '; - actionsHtml += ``; - }); - actionsHtml += '
'; - 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,