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