/**
* Custom Status Markers script
*
* ###### Required Scripts
* [Path Math](https://github.com/Roll20/roll20-api-scripts/tree/master/Path%20Math)
*
* Allows users to create custom status markers and set them onto tokens.
*
* ### Saving status markers
*
* 1) Draw your marker using the polygon or freestyle drawing tools.
* 2) Select your marker drawing and enter '!saveMarker {statusName}' in the
* chat, where {statusName} is the name you want to save the custom status marker
* as.
*
* e.g. '!saveMarker sleep'
*
* When the marker is saved, a confirmation message will be displayed in the chat.
*
* ### Setting/toggling status markers on tokens
*
* 1) Select one or more tokens to assign the status maker to.
* 2) In the chat, enter the '!setMarker {statusName} [{count}]',
* where {statusName} is the name of the saved custom status marker and [{count}]
* is an optional number badge to put on the status marker.
*
* If the status marker will be toggled for each selected token. If count is
* specified, the status marker will include count as a text badge.
*/
CustomStatusMarkers = (function() {
var SAVE_MARKER_CMD = '!saveMarker';
var SET_MARKER_CMD = '!setMarker';
var LIST_MARKERS_CMD = '!listMarkers';
var DEL_MARKER_CMD = '!delMarker';
var CLEAR_STATE_CMD = '!clearMarkersState';
var PIXELS_PER_SQUARE = 70;
/**
* A persisted template for a custom status marker.
* @typedef {object} StatusMarkerTemplate
* @property {string} type
* 'path' or 'graphic'
* @property {string} src
* For a path-type marker, this is the _path string defining its
* Path.
* For a graphic-type marker, this is the URL of its image.
* @property {PathMath.BoundingBox} bbox
* The marker's bounding box.
*/
/**
* A rendered custom status marker with an optional number badge.
* @typedef {object} StatusMarker
* @property {string} name
* The name of the status.
* @property {string} type
* 'path' or 'graphic'
* @property {uuid} iconId
* The _id of the marker's Path or Graphic.
* @property {uuid} [textId]
* The _id of the Text object for the marker's number badge.
* If omitted, then the marker has no badge.
* @property {uuid} tokenId
* The _id of the token the marker is assigned to.
* @property {int} [count]
* The displayed count for the badge.
*/
var statusListeners = {
'add': [],
'change': [],
'remove': []
};
/**
* Adds a custom status marker to a token, with an optional count badge.
* @param {Graphic} token
* @param {String} statusName
* @param {String} [count]
* @param {boolean} [silent=false]
*/
function addStatusMarker(token, statusName, count, silent) {
removeStatusMarker(token, statusName, true);
_createTokenStatusMarker(token, statusName, count);
repositionStatusMarkers(token);
if(!silent) {
var statusMarker = _getTokenState(token).customStatuses[statusName];
_fireAddEvent(token, statusMarker);
}
}
/**
* Calculates the left property for a status marker to be placed on a token.
* @private
* @param {Graphic} token
* @param {int} index
* @return {number}
*/
function _calcStatusMarkerLeft(token, index) {
var leftOffset = _calcStatusMarkerOffset(token, index);
return token.get('left') + token.get('width')/2 - PIXELS_PER_SQUARE/6 - leftOffset;
}
/**
* Calculates the left-offset for a StatusMarker on a token.
* @private
* @param {Graphic} token
* @param {int} index
*/
function _calcStatusMarkerOffset(token, index) {
var statusMarkers = token.get('statusmarkers');
if(statusMarkers)
statusMarkers = statusMarkers.split(',');
else
statusMarkers = [];
return (statusMarkers.length + index) * PIXELS_PER_SQUARE/3;
}
/**
* Calculates the top property of a status marker to be placed on a token.
* @private
* @param {Graphic} token
* @return {number}
*/
function _calcStatusMarkerTop(token) {
return token.get('top') - token.get('height')/2 + PIXELS_PER_SQUARE/6;
}
/**
* Clears the Custom Status Markers state for a particular token.
* @param {Graphic} token
*/
function clearTokenState(token) {
var csmState = getState();
var tokenId = token.get('_id');
delete csmState.tokens[tokenId];
}
/**
* Creates an instance of a status marker and assign it to a token.
* @private
* @param {Graphic} token
* @param {String} statusName
* @param {int} [count]
* Number for an optional icon badge.
*/
function _createTokenStatusMarker(token, statusName, count) {
var template = getTemplate(statusName);
var page = token.get('_pageid');
// Create the icon.
var icon;
if(template.type === 'path')
icon = _createTokenStatusMarkerPath(template, page);
else
icon = _createTokenStatusMarkerGraphic(template, page);
var iconId = icon.get('_id');
toFront(icon);
// Create the badge if count was specified.
var textId;
if(!_.isUndefined(count)) {
var text = _createTokenStatusMarkerText(count, page);
textId = text.get('_id');
toFront(text);
}
var tokenState = _getTokenState(token);
tokenState.customStatuses[statusName] = {
name: statusName,
type: template.type,
iconId: iconId,
textId: textId,
tokenId: token.get('_id'),
count: count
};
}
/**
* @private
*/
function _createTokenStatusMarkerGraphic(template, page) {
var width = template.bbox.width;
var height = template.bbox.height;
var scale = _getStatusMarkerIconScale(width, height);
return createObj('graphic', {
_pageid: page,
imgsrc: template.src,
layer: 'objects',
left: -9999,
top: -9999,
width: width*scale,
height: height*scale
});
}
/**
* @private
*/
function _createTokenStatusMarkerPath(template, page) {
var width = template.bbox.width;
var height = template.bbox.height;
var scale = _getStatusMarkerIconScale(width, height);
return createObj('path', {
_pageid: page,
_path: template.src,
layer: 'objects',
stroke: 'transparent',
fill: '#000',
left: -9999,
top: -9999,
width: width,
height: height,
scaleX: scale,
scaleY: scale
});
}
/**
* @private
*/
function _createTokenStatusMarkerText(count, page) {
return createObj('text', {
_pageid: page,
layer: 'objects',
color: '#f00',
text: count,
left: -9999,
top: -9999
});
}
/**
* Deletes a custom status marker template.
* @param {string} statusName
*/
function deleteTemplate(statusName) {
var csmState = getState();
delete csmState.templates[statusName];
sendChat('CustomStatus script', 'Deleted status ' + statusName);
}
/**
* Fires an 'add' custom status markers event.
* @private
* @param {string} event
* @param {Graphic} token
* @param {StatusMarker} marker
*/
function _fireAddEvent(token, marker) {
var handlers = statusListeners['add'];
_.each(handlers, function(handler) {
handler(token, _.clone(marker));
});
}
/**
* Fires a 'change' custom status markers event.
* @private
* @param {string} event
* @param {Graphic} token
* @param {StatusMarker} marker
*/
function _fireChangeEvent(token, marker) {
var handlers = statusListeners['change'];
_.each(handlers, function(handler) {
handler(token, _.clone(marker));
});
}
/**
* Fires a 'remove' custom status markers event.
* @private
* @param {string} event
* @param {Graphic} token
* @param {StatusMarker} marker
*/
function _fireRemoveEvent(token, marker) {
var handlers = statusListeners['remove'];
_.each(handlers, function(handler) {
handler(token, _.clone(marker));
});
}
/**
* Cookbook.getCleanImgsrc
* https://wiki.roll20.net/API:Cookbook#getCleanImgsrc
*/
function _getCleanImgsrc(imgsrc) {
var parts = imgsrc.match(/(.*\/images\/.*)(thumb|max)(.*)$/);
if(parts) {
return parts[1]+'thumb'+parts[3];
}
return;
}
/**
* Gets the BoundingBox of a Graphic.
* @private
* @param {Graphic} graphic
* @return {PathMath.BoundingBox}
*/
function _getGraphicBoundingBox(graphic) {
var left = graphic.get('left');
var top = graphic.get('top');
var width = graphic.get('width');
var height = graphic.get('height');
return new PathMath.BoundingBox(left, top, width, height);
}
/**
* Extracts the selected graphics from a chat message.
* @private
* @param {ChatMessage} msg
* @return {Graphic[]}
*/
function _getGraphicsFromMsg(msg) {
var result = [];
var selected = msg.selected;
if(selected) {
_.each(selected, function(s) {
var match = findObjs({
_type: 'graphic',
_id: s._id
})[0];
if(match)
result.push(match);
});
}
return result;
}
/**
* Extracts the selected paths from a chat message.
* @private
* @param {ChatMessage} msg
* @return {Path[]}
*/
function _getPathsFromMsg(msg) {
var result = [];
var selected = msg.selected;
if(selected) {
_.each(selected, function(s) {
var match = findObjs({
_type: 'path',
_id: s._id
})[0];
if(match)
result.push(match);
});
}
return result;
}
/**
* Returns this module's object for the Roll20 API state.
* @return {Object}
*/
function getState() {
if(!state.CustomStatusMarkers)
state.CustomStatusMarkers = {
tokens: {},
templates: {}
};
return state.CustomStatusMarkers;
}
/**
* Gets the names of all the custom status markers on a token.
* @param {Graphic} token
* @return {string[]}
*/
function getStatusMarkers(token) {
var tokenState = _getTokenState(token);
if(token) {
return _.keys(tokenState.customStatuses);
}
return [];
}
/**
* Returns the scale for a status marker's icon.
* @private
* @param {number} width
* @param {number} height
* @return {number}
*/
function _getStatusMarkerIconScale(width, height) {
var length = Math.max(width, height);
return PIXELS_PER_SQUARE / length / 3;
}
/**
* Loads a StatusMarkerTemplate from the module state.
* @param {String} statusName
* @param {Function(StatusMarkerTemplate)} callback
*/
function getTemplate(statusName, callback) {
var csmState = getState();
return csmState.templates[statusName];
}
/**
* Returns the Custom Status Markers state for a token.
* @private
* @param {Graphic} token
* @param {boolean} [createBlank: true] If the token state doesn't exist, create it.
* @return {Object}
*/
function _getTokenState(token, createBlank) {
if(createBlank === undefined)
createBlank = true;
var csmState = getState();
var tokenId = token.get('_id');
var tokenState = csmState.tokens[tokenId];
if(!tokenState && createBlank) {
tokenState = csmState.tokens[tokenId] = {
customStatuses: {}
};
}
return tokenState;
}
/**
* Checks if a token has the custom status marker with the specified name.
* @param {graphic} token
* @param {string} statusName
* @return {boolean}
* True iff the token has the custom status marker active.
*/
function hasStatusMarker(token, statusName) {
var tokenState = _getTokenState(token, false);
if(tokenState) {
return tokenState.customStatuses[statusName];
}
return false;
}
/**
* Registers a Custom Status Markers event handler.
* Each handler takes a token and a StatusMarker as parameters.
* The following events are supported: 'add', 'change', 'remove'
* @param {string} event
* @param {function} handler
*/
function onEvent(event, handler) {
if(statusListeners[event]) {
statusListeners[event].push(handler);
}
}
/**
* @private
* Process an API command to clear the Custom Status Markers state.
* If a token selected, then only the CSM state for that token will be cleared.
* If the 'tokens' option is specified, then only the CSM's tokens state
* will be cleared and its saved templates will be left intact.
* If 'tokens' isn't specified and no token is selected, then this will
* clear all the CSM state!
* @param {ChatMessage} msg
*/
function _processClearMarkersStateCmd(msg) {
var args = msg.content.split(' ');
var token = _getGraphicsFromMsg(msg)[0];
if(token) {
clearTokenState(token);
}
else if(args[1] === 'tokens') {
getState().tokens = {};
}
else {
delete state.CustomStatusMarkers;
}
}
/**
* @private
* Process an API command to delete a saved custom status marker.
* @param {ChatMessage} msg
*/
function _processDelMarkerCmd(msg) {
var args = msg.content.split(' ');
var statusName = args[1];
deleteTemplate(statusName);
}
/**
* @private
* Processes an API command to display the list of saved custom status markers.
*/
function _processListMarkersCmd() {
var csmState = getState();
log(csmState);
var names = [];
_.each(csmState.templates, function(tpl, name) {
names.push(name);
});
names.sort();
names = names.join('
');
sendChat('CustomStatus script', 'Saved markers:
' + names);
}
/**
* @private
* Processes an API command to create a custom status from a selected path.
* @param {ChatMessage} msg
*/
function _processSaveMarkerCmd(msg) {
var args = msg.content.split(' ');
var statusName = args[1];
var curPage = Campaign().get("playerpageid");
var paths = _getPathsFromMsg(msg);
var graphics = _getGraphicsFromMsg(msg);
// Save a path-based marker.
if(paths.length > 0)
saveTemplate(statusName, paths);
// Save a graphic-based marker.
else
saveTemplate(statusName, graphics[0]);
}
/**
* @private
* Process an API command to set a custom status to the selected tokens.
* @param {ChatMessage} msg
*/
function _processSetMarkerCmd(msg) {
var args = msg.content.split(' ');
var statusName = args[1];
var count = args[2];
var selectedTokens = _getGraphicsFromMsg(msg);
_.each(selectedTokens, function(token) {
toggleStatusMarker(token, statusName, count);
});
}
/**
* Deletes a custom status marker from a token.
* @param {Graphic} token
* @param {String} statusName
* @param {boolean} [silent=false]
* If true, events won't be fired.
* @fires change
* @fires remove
*/
function removeStatusMarker(token, statusName, silent) {
var csmState = getState();
var id = token.get('_id');
var tokenState = csmState.tokens[id];
if(tokenState) {
var statusMarker = tokenState.customStatuses[statusName];
if(!statusMarker)
return;
var type = statusMarker.type;
var icon = findObjs({
_pageid: token.get('_pageid'),
_type: type,
_id: statusMarker.iconId
})[0];
if(icon)
icon.remove();
var text = findObjs({
_pageid: token.get('_pageid'),
_type: 'text',
_id: statusMarker.textId
})[0];
if(text)
text.remove();
delete tokenState.customStatuses[statusName];
repositionStatusMarkers(token);
if(!silent)
_fireRemoveEvent(token, statusMarker);
}
}
/**
* Removes all custom status markers from a token.
* @param {Graphic} token
* @param {boolean} [silent=false]
* If true, events won't be fired.
*/
function removeStatusMarkers(token, silent) {
var csmState = getState();
var tokenState = _getTokenState(token, false);
if(tokenState) {
_.each(tokenState.customStatuses, function(statusMarker, statusName) {
removeStatusMarker(token, statusName, silent);
});
}
}
/**
* Moves a status marker to its token's current position.
* @private
* @param {Graphic} token
* @param {Object} statusMarker
* @param {int} index
* @throws {Error}
* An error is thrown if the marker's expected graphic and badge
* are not present. (e.g. someone deleted them)
*/
function _repositionTokenStatusMarker(token, statusMarker, index) {
var left = _calcStatusMarkerLeft(token, index);
var top = _calcStatusMarkerTop(token);
// Move the icon.
var icon = findObjs({
_pageid: token.get('_pageid'),
_type: statusMarker.type,
_id: statusMarker.iconId
})[0];
if(!icon)
throw new Error('Icon ' + statusMarker.iconId + ' is missing.');
icon.set('left', left);
icon.set('top', top);
toFront(icon);
// Move the badge, if the icon marker has one.
if(statusMarker.textId) {
var text = findObjs({
_pageid: token.get('_pageid'),
_type: 'text',
_id: statusMarker.textId
})[0];
if(!text)
throw new Error('Text ' + statusMarker.textId + ' is missing.');
text.set('left', left + PIXELS_PER_SQUARE/8);
text.set('top', top + PIXELS_PER_SQUARE/8);
toFront(text);
}
}
/**
* Moves a token's custom status markers to their correct positions.
* @param {Graphic} token
*/
function repositionStatusMarkers(token) {
var tokenState = _getTokenState(token, false);
if(tokenState) {
var index = 0;
_.each(tokenState.customStatuses, function(statusMarker, statusName) {
try {
_repositionTokenStatusMarker(token, statusMarker, index);
}
catch(err) {
// If there was an error while moving the marker (e.g.
// Someone deleted its graphic instead of unsetting it),
// then remove the status from the token's state and
// log a warning.
delete tokenState.customStatuses[statusName];
log('Custom Status Markers [WARN]: ' + err.message);
}
index++;
});
}
}
/**
* Persists a custom status marker.
* @param {String} statusName
* @param {(Graphic|Path|Path[])} icon
*/
function saveTemplate(statusName, icon) {
var csmState = getState();
var bbox, src, type;
// Path-type icons
if(_.isArray(icon)) {
type = 'path';
src = PathMath.mergePathStr(icon);
bbox = PathMath.getBoundingBox(icon);
}
else if(icon.get('_type') === 'path') {
type = 'path';
src = PathMath.mergePathStr([icon]);
bbox = PathMath.getBoundingBox(icon);
}
// Graphic-type icons
else {
type = 'graphic';
bbox = _getGraphicBoundingBox(icon);
src = _getCleanImgsrc(icon.get('imgsrc'));
}
csmState.templates[statusName] = {
type: type,
bbox: bbox,
src: src
};
sendChat('CustomStatus script', 'Created status ' + statusName);
}
/**
* Toggles a custom status marker on a token, with an optional count badge.
* @param {Graphic} token
* @param {String} statusName
* @param {String} [count]
* @param {boolean} [silent=false]
*/
function toggleStatusMarker(token, statusName, count, silent) {
var tokenState = _getTokenState(token);
var statusMarker = tokenState.customStatuses[statusName];
if(statusMarker) {
var hasCount = !!statusMarker.textId;
if(hasCount || count) {
addStatusMarker(token, statusName, count, true);
if(!silent) {
var statusMarker = _getTokenState(token).customStatuses[statusName];
_fireChangeEvent(token, statusMarker);
}
}
else
removeStatusMarker(token, statusName, silent);
}
else
addStatusMarker(token, statusName, count, silent);
}
/**
* Removes a custom status marker event handler.
* @param {string} event
* @param {function} handler
*/
function unEvent(event, handler) {
var handlers = statusListeners[event];
if(handlers) {
var index = handlers.indexOf(handler);
if(index !== -1)
handlers.splice(index, 1);
}
}
// Event handler for the script's API chat commands.
on('chat:message', function(msg) {
try {
if(msg.content.indexOf(SAVE_MARKER_CMD) === 0)
_processSaveMarkerCmd(msg);
else if(msg.content.indexOf(SET_MARKER_CMD) === 0)
_processSetMarkerCmd(msg);
else if(msg.content.indexOf(LIST_MARKERS_CMD) === 0)
_processListMarkersCmd(msg);
else if(msg.content.indexOf(DEL_MARKER_CMD) === 0)
_processDelMarkerCmd(msg);
else if(msg.content.indexOf(CLEAR_STATE_CMD) === 0)
_processClearMarkersStateCmd(msg);
}
catch(err) {
sendChat('Custom status markers Error', '/w ' + msg.who + ' bad command: ' + msg.content);
log('Custom Status Markers ERROR: ' + err.message);
}
});
// Event handler for moving custom status markers with their tokens when
// they are moved.
on('change:graphic', function(graphic) {
repositionStatusMarkers(graphic);
});
// Event handler for destroying a token's custom status markers when the
// token is destroyed.
on('destroy:graphic', function(graphic) {
removeStatusMarkers(graphic);
});
return {
addStatusMarker: addStatusMarker,
clearTokenState: clearTokenState,
deleteTemplate: deleteTemplate,
getState: getState,
getStatusMarkers: getStatusMarkers,
getTemplate: getTemplate,
hasStatusMarker: hasStatusMarker,
on: onEvent,
removeStatusMarker: removeStatusMarker,
removeStatusMarkers: removeStatusMarkers,
repositionStatusMarkers: repositionStatusMarkers,
saveTemplate: saveTemplate,
toggleStatusMarker: toggleStatusMarker,
un: unEvent
};
})();