From 4ad88fade694c4a72dafdc56e9b7e6a510741bda Mon Sep 17 00:00:00 2001 From: Cazra Date: Wed, 27 Nov 2019 10:36:01 -0500 Subject: [PATCH 1/2] Initialized Node development environment for Marching Order script. --- MarchingOrder/.gitignore | 1 + MarchingOrder/Gruntfile.js | 98 ++++++++++++++++++++++++++++++++++++++ MarchingOrder/package.json | 10 ++++ 3 files changed, 109 insertions(+) create mode 100644 MarchingOrder/.gitignore create mode 100644 MarchingOrder/Gruntfile.js create mode 100644 MarchingOrder/package.json diff --git a/MarchingOrder/.gitignore b/MarchingOrder/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/MarchingOrder/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/MarchingOrder/Gruntfile.js b/MarchingOrder/Gruntfile.js new file mode 100644 index 0000000000..03e06a3292 --- /dev/null +++ b/MarchingOrder/Gruntfile.js @@ -0,0 +1,98 @@ +'use strict'; + +module.exports = function(grunt) { + grunt.initConfig({ + pkg: grunt.file.readJSON('script.json'), + concat: { + dist: { + src: [ + 'src/index.js', + 'src/Commands.js', + 'src/Config.js', + 'src/Events.js', + 'src/Macros.js', + 'src/Paths.js', + 'src/State.js', + 'src/Templates.js', + 'src/Wizard.js', + 'src/utils/index.js', + 'src/utils/Chat.js', + 'src/utils/Menu.js' + ], + dest: '<%= pkg.version %>/<%= pkg.script %>' + } + }, + jshint: { + dist: { + src: ['src/**/*.js'] + }, + options: { + eqeqeq: true, + esversion: 6, + freeze: true, + globals: { + // Symbols defined by API scripts + CustomStatusMarkers: true, + PathMath: false, + + // Symbols defined by Roll20 + _: false, + createObj: false, + findObjs: false, + getObj: false, + globalconfig: true, + playerIsGM: false, + log: false, + on: false, + sendChat: false, + state: true, + toBack: false, + toFront: false + }, + nonbsp: true, + nonew: true, + strict: true, + undef: true, + unused: true + } + }, + 'string-replace': { + dist: { + files: { + '<%= pkg.version %>/<%= pkg.script %>': '<%= pkg.version %>/<%= pkg.script %>' + } + }, + options: { + replacements: [ + { + pattern: 'SCRIPT_VERSION', + replacement: '<%= pkg.version %>' + }, + + // Convert unicode characters to HTML entities. + { + pattern: /[\u{00FF}-\u{FFFFF}]/gu, + replacement: function(match) { + if (match.length === 2) { + let highSurrogate = match.charCodeAt(0); + let lowSurrogate = match.charCodeAt(1); + let astralCodePoint = (highSurrogate - 0xD800) * 0x400 + lowSurrogate - 0xDC00 + 0x10000; + return '&#' + astralCodePoint + ';'; + } + else if (match.length === 1) { + let charCode = match.charCodeAt(0); + return '&#' + charCode + ';'; + } + } + } + ] + } + } + }); + + grunt.loadNpmTasks('grunt-contrib-concat'); + grunt.loadNpmTasks('grunt-contrib-jshint'); + grunt.loadNpmTasks('grunt-string-replace'); + + grunt.registerTask('default', ['jshint', 'concat', 'string-replace']); +}; diff --git a/MarchingOrder/package.json b/MarchingOrder/package.json new file mode 100644 index 0000000000..325c04933b --- /dev/null +++ b/MarchingOrder/package.json @@ -0,0 +1,10 @@ +{ + "name": "MarchingOrder", + "description": "See script.json", + "devDependencies": { + "grunt": "^1.0.1", + "grunt-contrib-concat": "^1.0.1", + "grunt-contrib-jshint": "^1.1.0", + "grunt-string-replace": "^1.3.0" + } +} From 6c2cb1fa857c177907adb5be4d1dd882eee7f6ff Mon Sep 17 00:00:00 2001 From: Cazra Date: Wed, 27 Nov 2019 13:06:32 -0500 Subject: [PATCH 2/2] Refactored MarchingOrder script to have a cleaner, more modular source tree. --- MarchingOrder/2.4/marchingOrder.js | 659 +++++++++++++++++++++++++++++ MarchingOrder/Gruntfile.js | 7 +- MarchingOrder/README.md | 10 +- MarchingOrder/script.json | 6 +- MarchingOrder/src/Commands.js | 117 +++++ MarchingOrder/src/Config.js | 38 ++ MarchingOrder/src/Macros.js | 49 +++ MarchingOrder/src/State.js | 68 +++ MarchingOrder/src/Wizard.js | 59 +++ MarchingOrder/src/index.js | 207 +++++++++ MarchingOrder/src/utils/Chat.js | 59 +++ MarchingOrder/src/utils/Menu.js | 31 ++ MarchingOrder/src/utils/index.js | 23 + 13 files changed, 1322 insertions(+), 11 deletions(-) create mode 100644 MarchingOrder/2.4/marchingOrder.js create mode 100644 MarchingOrder/src/Commands.js create mode 100644 MarchingOrder/src/Config.js create mode 100644 MarchingOrder/src/Macros.js create mode 100644 MarchingOrder/src/State.js create mode 100644 MarchingOrder/src/Wizard.js create mode 100644 MarchingOrder/src/index.js create mode 100644 MarchingOrder/src/utils/Chat.js create mode 100644 MarchingOrder/src/utils/Menu.js create mode 100644 MarchingOrder/src/utils/index.js diff --git a/MarchingOrder/2.4/marchingOrder.js b/MarchingOrder/2.4/marchingOrder.js new file mode 100644 index 0000000000..750dc51eba --- /dev/null +++ b/MarchingOrder/2.4/marchingOrder.js @@ -0,0 +1,659 @@ +var MarchingOrder = (() => { + 'use strict'; + + /** + * Makes a token's followers move to the token's previous position. + * @param {Graphic} leader + */ + 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); + + if(typeof CustomStatusMarkers !== 'undefined') + CustomStatusMarkers.repositionStatusMarkers(follower); + } + + /** + * Makes a single token follow another token. + * @param {Graphic} leader + * @param {Graphic} follower + */ + function follow(leader, follower) { + if(!leader || !follower) + return; + + // unbind all of follower's following links. + unfollow(follower); + + let prevFollower = leader.follower; + follower.leader = leader; + follower.follower = prevFollower; + + leader.follower = follower; + if(prevFollower) + prevFollower.leader = follower; + } + + /** + * 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); + }); + setMarchingOrder(tokens); + } + + /** + * Makes a chain of tokens follow some other token. + * @param {Graphic} follower + * @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; + } + tokens.unshift(leader); + setMarchingOrder(tokens); + } + + + + /** + * Sets a marching order for an array of tokens, with the token at index 0 + * being the leader. + * @param {Graphic[]} + */ + function setMarchingOrder(tokens) { + _.each(_.range(tokens.length-1), i => { + let leader = tokens[i]; + let follower = tokens[i+1]; + + MarchingOrder.utils.Chat.broadcast(follower.get("name") + " is following " + leader.get("name")); + follow(leader, follower); + }); + } + + /** + * Makes a token stop following other tokens. + * @param {Graphic} token + */ + 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; + } + + /** + * Makes all tokens stop following each other. + */ + function unfollowAll() { + let allObjs = findObjs({ + _type: 'graphic', + layer: 'objects' + }); + _.each(allObjs, obj => { + unfollow(obj); + }); + + MarchingOrder.utils.Chat.broadcast("Tokens are no longer following each other."); + } + + /** + * 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); + }); + setMarchingOrder(tokens); + } + + + // 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 v2.4 ---'); + }); + + /** + * Set up an event handler to do the marching order effect when the + * leader tokens move! + */ + on("change:graphic", (obj, prev) => { + 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; + } + } + } + catch(err) { + MarchingOrder.utils.Chat.error(err); + } + }); + + return { + follow: follow, + followAllDirection, + followAllToken, + set: setMarchingOrder, + unfollow, + unfollowAll, + useDefaultMarchingOrder + }; +})(); + +/** + * This module defines and implements the chat commands used by this script. + */ +(() => { + 'use strict'; + + const MENU_CMD = '!showMarchingOrderMenu'; + const FOLLOW_CMD = '!marchingOrderFollow'; + const STOP_ALL_CMD = '!marchingOrderStopAll'; + const DEFAULT_USE_CMD = '!marchingOrderUseDefault'; + const DEFAULT_SET_CMD = '!marchingOrderSetDefault'; + + /** + * Extracts the selected graphics from a chat message. + * @param {ChatMessage} msg + * @return {Graphic[]} + */ + function _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; + } + + /** + * Process an API command to have tokens follow each other. + */ + 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)}`); + } + } + + /** + * Process an API command to set the default marching order. + */ + function _cmdSetDefaultMarchingOrder(msg) { + let argv = msg.content.split(' '); + let leader = getObj('graphic', argv[1]); + if (leader) { + MarchingOrder.Config.setDefaultMarchingOrder(leader); + menu(msg); + } + else + throw new Error(`Leader token not found for DEFAULT_SET_CMD: ${argv.slice(1)}`); + } + + function _cmdUnfollowAll(msg) { + _.noop(msg); + MarchingOrder.unfollowAll(); + } + + /** + * Process an aPI command to use the default marching order. + */ + function _cmdUseDefaultMarchingOrder(msg) { + _.noop(msg); + MarchingOrder.useDefaultMarchingOrder(); + } + + /** + * Processes an API command to display the script's main menu. + */ + function menu(msg) { + let player = getObj('player', msg.playerid); + MarchingOrder.Wizard.show(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] === 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] === STOP_ALL_CMD) + _cmdUnfollowAll(msg); + } + catch(err) { + MarchingOrder.utils.Chat.error(err); + } + }); + + /** + * Expose the command constants for use in other modules. + */ + MarchingOrder.Commands = { + DEFAULT_SET_CMD, + DEFAULT_USE_CMD, + FOLLOW_CMD, + MENU_CMD, + STOP_ALL_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 { + + /** + * 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 = { + defaultOrder: [] + }; + 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'); + 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})
'; + + 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})'; + } + } + + return new Menu('Marching Order', actionsHtml); + } + + static show(player) { + let menu = MarchingOrder.Wizard.getMainMenu(player); + menu.show(player); + } + }; +})(); + +/** + * utils package + */ +(() => { + 'use strict'; + + /** + * Cookbook.getCleanImgsrc + * https://wiki.roll20.net/API:Cookbook#getCleanImgsrc + */ + function getCleanImgsrc(imgsrc) { + let parts = imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)(.*)$/); + if(parts) + return parts[1]+'thumb'+parts[3]; + 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.'); + } + + 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(); + } + + /** + * 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 03e06a3292..e2ef071de2 100644 --- a/MarchingOrder/Gruntfile.js +++ b/MarchingOrder/Gruntfile.js @@ -9,11 +9,8 @@ module.exports = function(grunt) { 'src/index.js', 'src/Commands.js', 'src/Config.js', - 'src/Events.js', 'src/Macros.js', - 'src/Paths.js', 'src/State.js', - 'src/Templates.js', 'src/Wizard.js', 'src/utils/index.js', 'src/utils/Chat.js', @@ -33,10 +30,11 @@ module.exports = function(grunt) { globals: { // Symbols defined by API scripts CustomStatusMarkers: true, - PathMath: false, + MarchingOrder: true, // Symbols defined by Roll20 _: false, + Campaign: false, createObj: false, findObjs: false, getObj: false, @@ -52,6 +50,7 @@ module.exports = function(grunt) { nonbsp: true, nonew: true, strict: true, + sub: true, undef: true, unused: true } diff --git a/MarchingOrder/README.md b/MarchingOrder/README.md index 7c9791cc05..8f7e4dac3d 100644 --- a/MarchingOrder/README.md +++ b/MarchingOrder/README.md @@ -1,8 +1,7 @@ # Marching Order -_v2.3 Updates_ -* Added button to stop all tokens from following each other. -* Added capability to set a default marching order. +_v2.4 Updates_ +* Code refactored into modules. This script allows you to select tokens and tell them to follow each other in some specified marching order. @@ -65,6 +64,9 @@ or create a help thread on the Roll20 API forum ## Show Support If you would like to show your appreciation and support for the work I do in writing, -updating, 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) +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/script.json b/MarchingOrder/script.json index 25bc266c75..5b5feb585a 100644 --- a/MarchingOrder/script.json +++ b/MarchingOrder/script.json @@ -1,9 +1,9 @@ { "name": "Marching Order", "script": "marchingOrder.js", - "version": "2.3", - "previousversions": ["2.0", "2.1", "2.2"], - "description": "# Marching Order\r\r_v2.3 Updates_\r* Added button to stop all tokens from following each other.\r* Added capability to set a default marching order.\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, 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", + "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", "authors": "Stephen Lindberg", "roll20userid": 46544, "useroptions": [], diff --git a/MarchingOrder/src/Commands.js b/MarchingOrder/src/Commands.js new file mode 100644 index 0000000000..850fb1f7e5 --- /dev/null +++ b/MarchingOrder/src/Commands.js @@ -0,0 +1,117 @@ +/** + * This module defines and implements the chat commands used by this script. + */ +(() => { + 'use strict'; + + const MENU_CMD = '!showMarchingOrderMenu'; + const FOLLOW_CMD = '!marchingOrderFollow'; + const STOP_ALL_CMD = '!marchingOrderStopAll'; + const DEFAULT_USE_CMD = '!marchingOrderUseDefault'; + const DEFAULT_SET_CMD = '!marchingOrderSetDefault'; + + /** + * Extracts the selected graphics from a chat message. + * @param {ChatMessage} msg + * @return {Graphic[]} + */ + function _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; + } + + /** + * Process an API command to have tokens follow each other. + */ + 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)}`); + } + } + + /** + * Process an API command to set the default marching order. + */ + function _cmdSetDefaultMarchingOrder(msg) { + let argv = msg.content.split(' '); + let leader = getObj('graphic', argv[1]); + if (leader) { + MarchingOrder.Config.setDefaultMarchingOrder(leader); + menu(msg); + } + else + throw new Error(`Leader token not found for DEFAULT_SET_CMD: ${argv.slice(1)}`); + } + + function _cmdUnfollowAll(msg) { + _.noop(msg); + MarchingOrder.unfollowAll(); + } + + /** + * Process an aPI command to use the default marching order. + */ + function _cmdUseDefaultMarchingOrder(msg) { + _.noop(msg); + MarchingOrder.useDefaultMarchingOrder(); + } + + /** + * Processes an API command to display the script's main menu. + */ + function menu(msg) { + let player = getObj('player', msg.playerid); + MarchingOrder.Wizard.show(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] === 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] === STOP_ALL_CMD) + _cmdUnfollowAll(msg); + } + catch(err) { + MarchingOrder.utils.Chat.error(err); + } + }); + + /** + * Expose the command constants for use in other modules. + */ + MarchingOrder.Commands = { + DEFAULT_SET_CMD, + DEFAULT_USE_CMD, + FOLLOW_CMD, + MENU_CMD, + STOP_ALL_CMD + }; +})(); diff --git a/MarchingOrder/src/Config.js b/MarchingOrder/src/Config.js new file mode 100644 index 0000000000..b3e9e8fc7a --- /dev/null +++ b/MarchingOrder/src/Config.js @@ -0,0 +1,38 @@ +(() => { + '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; + } + }; +})(); diff --git a/MarchingOrder/src/Macros.js b/MarchingOrder/src/Macros.js new file mode 100644 index 0000000000..5bbed11caf --- /dev/null +++ b/MarchingOrder/src/Macros.js @@ -0,0 +1,49 @@ +(() => { + '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); + }); + } + }; +})(); diff --git a/MarchingOrder/src/State.js b/MarchingOrder/src/State.js new file mode 100644 index 0000000000..1efce16882 --- /dev/null +++ b/MarchingOrder/src/State.js @@ -0,0 +1,68 @@ +(() => { + 'use strict'; + + /** + * This module provides an interface to the script's state. + */ + MarchingOrder.State = class { + + /** + * 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 = { + defaultOrder: [] + }; + 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); + } + }; +})(); diff --git a/MarchingOrder/src/Wizard.js b/MarchingOrder/src/Wizard.js new file mode 100644 index 0000000000..a24eae1c28 --- /dev/null +++ b/MarchingOrder/src/Wizard.js @@ -0,0 +1,59 @@ +(() => { + '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'); + 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})
'; + + 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})'; + } + } + + return new Menu('Marching Order', actionsHtml); + } + + static show(player) { + let menu = MarchingOrder.Wizard.getMainMenu(player); + menu.show(player); + } + }; +})(); diff --git a/MarchingOrder/src/index.js b/MarchingOrder/src/index.js new file mode 100644 index 0000000000..b633bb89c6 --- /dev/null +++ b/MarchingOrder/src/index.js @@ -0,0 +1,207 @@ +var MarchingOrder = (() => { + 'use strict'; + + /** + * Makes a token's followers move to the token's previous position. + * @param {Graphic} leader + */ + 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); + + if(typeof CustomStatusMarkers !== 'undefined') + CustomStatusMarkers.repositionStatusMarkers(follower); + } + + /** + * Makes a single token follow another token. + * @param {Graphic} leader + * @param {Graphic} follower + */ + function follow(leader, follower) { + if(!leader || !follower) + return; + + // unbind all of follower's following links. + unfollow(follower); + + let prevFollower = leader.follower; + follower.leader = leader; + follower.follower = prevFollower; + + leader.follower = follower; + if(prevFollower) + prevFollower.leader = follower; + } + + /** + * 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); + }); + setMarchingOrder(tokens); + } + + /** + * Makes a chain of tokens follow some other token. + * @param {Graphic} follower + * @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; + } + tokens.unshift(leader); + setMarchingOrder(tokens); + } + + + + /** + * Sets a marching order for an array of tokens, with the token at index 0 + * being the leader. + * @param {Graphic[]} + */ + function setMarchingOrder(tokens) { + _.each(_.range(tokens.length-1), i => { + let leader = tokens[i]; + let follower = tokens[i+1]; + + MarchingOrder.utils.Chat.broadcast(follower.get("name") + " is following " + leader.get("name")); + follow(leader, follower); + }); + } + + /** + * Makes a token stop following other tokens. + * @param {Graphic} token + */ + 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; + } + + /** + * Makes all tokens stop following each other. + */ + function unfollowAll() { + let allObjs = findObjs({ + _type: 'graphic', + layer: 'objects' + }); + _.each(allObjs, obj => { + unfollow(obj); + }); + + MarchingOrder.utils.Chat.broadcast("Tokens are no longer following each other."); + } + + /** + * 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); + }); + setMarchingOrder(tokens); + } + + + // 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 ---'); + }); + + /** + * Set up an event handler to do the marching order effect when the + * leader tokens move! + */ + on("change:graphic", (obj, prev) => { + 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; + } + } + } + catch(err) { + MarchingOrder.utils.Chat.error(err); + } + }); + + return { + follow: follow, + followAllDirection, + followAllToken, + set: setMarchingOrder, + unfollow, + unfollowAll, + useDefaultMarchingOrder + }; +})(); diff --git a/MarchingOrder/src/utils/Chat.js b/MarchingOrder/src/utils/Chat.js new file mode 100644 index 0000000000..e9a93b70e3 --- /dev/null +++ b/MarchingOrder/src/utils/Chat.js @@ -0,0 +1,59 @@ +(() => { + '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(); + } + + /** + * 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); + } + }; +})(); diff --git a/MarchingOrder/src/utils/Menu.js b/MarchingOrder/src/utils/Menu.js new file mode 100644 index 0000000000..9629684450 --- /dev/null +++ b/MarchingOrder/src/utils/Menu.js @@ -0,0 +1,31 @@ +(() => { + '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/src/utils/index.js b/MarchingOrder/src/utils/index.js new file mode 100644 index 0000000000..8ae82bdc4f --- /dev/null +++ b/MarchingOrder/src/utils/index.js @@ -0,0 +1,23 @@ +/** + * utils package + */ +(() => { + 'use strict'; + + /** + * Cookbook.getCleanImgsrc + * https://wiki.roll20.net/API:Cookbook#getCleanImgsrc + */ + function getCleanImgsrc(imgsrc) { + let parts = imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)(.*)$/); + if(parts) + return parts[1]+'thumb'+parts[3]; + 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.'); + } + + MarchingOrder.utils = { + getCleanImgsrc + }; +})();