/**
* 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;
var SAVE_HANDOUT_NAME = 'SavedCustomstatusMarkers';
/**
* A class for persisted custom status markers.
* @param {String} path
* @param {[type]} bbox
*/
function StatusMarkerTemplate(pathStr, bbox, imgSrc) {
this.pathStr = pathStr;
this.bbox = bbox;
this.imgSrc = imgSrc;
};
/**
* A class encapsulating a Path for a custom status marker, with an optional
* Text for a number badge.
* @param {uuid} iconId
* @param {string} 'path' or 'graphic'
* @param {uuid} textId
*/
function StatusMarker(iconId, type, textId) {
this.iconId = iconId;
this.type = type;
this.textId = textId;
};
/**
* @private
* Calculates the left property for a status marker to be placed on a token.
* @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;
};
/**
* @private
* Calculates the left-offset for a StatusMarker on a token.
* @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;
};
/**
* @private
* Calculates the top property of a status marker to be placed on a token.
* @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 to assign to a token,
* with an optional count badge.
* @param {Graphic} token
* @param {String} statusName
* @param {int} [count]
*/
function createTokenStatusMarker(token, statusName, count, index) {
var template = loadTemplate(statusName);
var pathStr = template.pathStr;
var imgSrc = template.imgSrc;
var bbox = template.bbox;
var width = bbox.width;
var height = bbox.height;
var left = _calcStatusMarkerLeft(token, index);
var top = _calcStatusMarkerTop(token);
var page = token.get('_pageid');
var icon;
var type;
if(pathStr) {
icon = _createTokenStatusMarkerPath(pathStr, left, top, width,
height, page);
type = 'path';
}
else {
icon = _createTokenStatusMarkerGraphic(imgSrc, left, top, width,
height, page);
type = 'graphic';
}
var iconId = icon.get('_id');
toFront(icon);
var textId;
if(count) {
var text = _createTokenStatusMarkerText(count, left, top, page);
textId = text.get('_id');
toFront(text);
}
var tokenState = getTokenState(token);
tokenState.customStatuses[statusName] = new StatusMarker(iconId, type,
textId);
tokenState.customStatusesCount++;
};
/**
* @private
* @param {String} imgSrc
* @param {number} left
* @param {number}} top
* @param {number} width
* @param {number} height
* @return {Graphic}
*/
function _createTokenStatusMarkerGraphic(imgSrc, left, top, width, height,
page) {
var scale = getStatusMarkerIconScale(width, height);
return createObj('graphic', {
_pageid: page,
imgsrc: imgSrc,
layer: 'objects',
left: left,
top: top,
width: width*scale,
height: height*scale
});
};
/**
* @private
* @param {String} pathStr
* @param {number} left
* @param {number} top
* @param {number} width
* @param {number} height
* @return {Path}
*/
function _createTokenStatusMarkerPath(pathStr, left, top, width, height,
page) {
var scale = getStatusMarkerIconScale(width, height);
return createObj('path', {
_pageid: page,
_path: pathStr,
layer: 'objects',
stroke: 'transparent',
fill: '#000',
left: left,
top: top,
width: width,
height: height,
scaleX: scale,
scaleY: scale
});
};
/**
* @private
* @param {String} count
* @param {number} left
* @param {number} top
* @return {Text}
*/
function _createTokenStatusMarkerText(count, left, top, page) {
return createObj('text', {
_pageid: page,
layer: 'objects',
color: '#f00',
text: count,
left: left + PIXELS_PER_SQUARE/8,
top: top + PIXELS_PER_SQUARE/8
});
};
/**
* Deletes a custom status marker.
* @param {string} statusName
*/
function deleteStatusMarker(statusName) {
var csmState = getState();
delete csmState.templates[statusName];
sendChat('CustomStatus script', 'Deleted status ' + statusName);
};
/**
* Deletes a custom status marker from a token.
* @param {Graphic} token
* @param {String} statusName
*/
function deleteTokenStatusMarker(token, statusName) {
var csmState = getState();
var id = token.get('_id');
var tokenState = csmState.tokens[id];
if(tokenState) {
var statusMarker = tokenState.customStatuses[statusName];
var type = statusMarker.type;
var icon = findObjs({
_page: _getCurPage(),
_type: type,
_id: statusMarker.iconId
})[0];
icon.remove();
var text = findObjs({
_page: _getCurPage(),
_type: 'text',
_id: statusMarker.textId
})[0];
if(text)
text.remove();
delete tokenState.customStatuses[statusName];
tokenState.customStatusesCount--;
}
};
/**
* 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;
};
/**
* @private
* Gets the current players' page.
* @return {uuid}
*/
function _getCurPage() {
var curPage = Campaign().get("playerpageid");
};
/**
* @private
* Gets the BoundingBox of a Graphic.
* @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);
};
/**
* @private
* 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, 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.
* @param {ChatMessage} msg
* @return {Path[]}
*/
function _getPathsFromMsg(msg) {
var result = [];
var selected = msg.selected;
if(selected) {
_.each(selected, function(s) {
var matches = findObjs({
_type: 'path',
_id: s._id
});
if(matches && matches.length > 0) {
result.push(matches[0]);
}
});
}
return result;
};
/**
* Returns this module's object for the Roll20 API state.
* @return {Object}
*/
function getState() {
if(!state.CustomStatusMarkersModule)
state.CustomStatusMarkersModule = {
tokens: {},
templates: {}
};
return state.CustomStatusMarkersModule;
};
/**
* Returns the scale for a status marker's icon.
* @param {number} width
* @param {number} height
* @return {number}
*/
function getStatusMarkerIconScale(width, height) {
var length = Math.max(width, height);
return PIXELS_PER_SQUARE / length / 3;
};
/**
* Returns the Custom Status Markers state for a token.
* @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: {},
customStatusesCount: 0
};
}
return tokenState;
};
/**
* Loads a StatusMarkerTemplate from the save handout.
* @param {String} statusName
* @param {Function(StatusMarkerTemplate)} callback
*/
function loadTemplate(statusName, callback) {
var csmState = getState();
var tpl = csmState.templates[statusName];
return new StatusMarkerTemplate(tpl.pathStr, tpl.bbox, tpl.imgSrc);
};
/**
* Moves a status marker to its token's current position.
* @param {Graphic} token
* @param {Object} statusMarker
* @param {int} index
* @return {string} An error message. Undefined if no error.
*/
function moveTokenStatusMarker(token, statusMarker, index) {
var left = _calcStatusMarkerLeft(token, index);
var top = _calcStatusMarkerTop(token);
var icon = findObjs({
_page: _getCurPage(),
_type: statusMarker.type,
_id: statusMarker.iconId
})[0];
if(!icon)
return 'Icon ' + statusMarker.iconId + ' is missing.';
icon.set('left', left);
icon.set('top', top);
toFront(icon);
if(statusMarker.textId) {
var text = findObjs({
_page: _getCurPage(),
_type: 'text',
_id: statusMarker.textId
})[0];
if(!text)
return 'Text ' + statusMarker.textId + ' is missing.';
text.set('left', left + PIXELS_PER_SQUARE/8);
text.set('top', top + PIXELS_PER_SQUARE/8);
toFront(text);
}
};
/**
* @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.CustomStatusMarkersModule;
}
};
/**
* @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];
deleteStatusMarker(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) {
var bbox = PathMath.getBoundingBox(paths);
var mergedPathStr = PathMath.mergePathStr(paths);
saveTemplate(statusName, mergedPathStr, bbox, undefined);
}
// Save a graphic-based marker.
else {
var bbox = _getGraphicBoundingBox(graphics[0]);
var imgSrc = graphics[0].get('imgsrc');
saveTemplate(statusName, undefined, bbox, imgSrc);
}
};
/**
* @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) {
toggleStatusToToken(token, statusName, count);
});
};
/**
* Moves a custom status for a token by deleting it and then recreating it at
* the token's current location.
* @param {Graphic} token
* @param {String} statusName
* @param {String} count
*/
function replaceTokenStatusMarker(token, statusName, count, index) {
deleteTokenStatusMarker(token, statusName);
createTokenStatusMarker(token, statusName, count, index);
};
/**
* Persists a custom status marker.
* @param {String} statusName
* @param {String} pathStr
* @param {BoundingBox} bbox
* @param {String} imgSrc
*/
function saveTemplate(statusName, pathStr, bbox, imgSrc) {
var csmState = getState();
if(imgSrc)
imgSrc = _getCleanImgsrc(imgSrc); //imgSrc.replace(/(max|med)\.png/,'thumb.png');
csmState.templates[statusName] = new StatusMarkerTemplate(pathStr, bbox, imgSrc);
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]
*/
function toggleStatusToToken(token, statusName, count) {
var tokenState = getTokenState(token);
var statusMarker = tokenState.customStatuses[statusName];
if(statusMarker) {
var hasCount = !!statusMarker.textId;
if(hasCount || count)
replaceTokenStatusMarker(token, statusName, count, tokenState.customStatusesCount-1);
else
deleteTokenStatusMarker(token, statusName);
}
else
createTokenStatusMarker(token, statusName, count, tokenState.customStatusesCount);
};
/**
* Transfers saved Custom Status Marker templates from older versions
* (which persisted them in Handouts) to
* the new version (which persists them in the 'state').
*/
function _transferLegacyMarkers() {
var saveHandout = findObjs({
_type: 'handout',
name: SAVE_HANDOUT_NAME
})[0];
// If there are legacy markers, transfer them.
if(saveHandout) {
saveHandout.get('notes', function(notes) {
var oldTemplates = JSON.parse(notes);
var csmState = getState();
_.each(oldTemplates, function(oldTpl, name) {
var bbox = oldTpl.bbox;
var imgSrc = oldTpl.imgSrc;
var pathStr = oldTpl.pathStr;
var newTpl = new StatusMarkerTemplate (pathStr, bbox, imgSrc);
csmState.templates[name] = newTpl;
});
});
// When we're done, delete the old Handout used to persist the
// legacy markers.
saveHandout.remove();
}
};
// Automatically transfer any legacy markers.
on('ready', function() {
_transferLegacyMarkers();
});
// 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(err);
}
});
// Event handler for moving custom status markers with their tokens when
// they are moved.
on('change:graphic', function(graphic) {
var tokenState = getTokenState(graphic, false);
if(tokenState) {
var index = 0;
_.each(tokenState.customStatuses, function(statusMarker, statusName) {
var errorMsg = moveTokenStatusMarker(graphic, statusMarker, index);
// 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.
if(errorMsg) {
delete tokenState.customStatuses[statusName];
tokenState.customStatusesCount--;
log('Custom Status Markers [WARN]: ' + errorMsg);
}
index++;
});
}
});
// Event handler for destroying a token's custom status markers when the
// token is destroyed.
on('destroy:graphic', function(graphic) {
var csmState = getState();
var tokenState = getTokenState(graphic, false);
if(tokenState) {
_.each(tokenState.customStatuses, function(statusMarker, statusName) {
deleteTokenStatusMarker(graphic, statusName);
clearTokenState(graphic);
});
}
});
return {
StatusMarkerTemplate: StatusMarkerTemplate,
StatusMarker: StatusMarker,
clearTokenState: clearTokenState,
createTokenStatusMarker: createTokenStatusMarker,
deleteStatusMarker: deleteStatusMarker,
deleteTokenStatusMarker: deleteTokenStatusMarker,
getState: getState,
getTokenState: getTokenState,
getStatusMarkerIconScale: getStatusMarkerIconScale,
loadTemplate: loadTemplate,
replaceTokenStatusMarker: replaceTokenStatusMarker,
saveTemplate: saveTemplate,
toggleStatusToToken: toggleStatusToToken
};
})();