// Github: https://github.com/symposion/roll20-api-scripts/
// By: Lucian Holland
//Who the hell thought that it was a good idea to use % as "remainder"
//instead of modulus like every other sensible programming language? That's
//two hours of my life I'll never get back.
var mod = function(n, m) {
return ((n%m)+m)%m;
};
var DynamicLightRecorder = DynamicLightRecorder || (function() {
'use strict';
var version = '1.0.2',
schemaVersion = 0.8,
clearURL = 'https://s3.amazonaws.com/files.d20.io/images/4277467/iQYjFOsYC5JsuOPUCI9RGA/thumb.png?1401938659',
myState = state.DynamicLightRecorder,
booleanValidator = function(value) {
var converted = value === 'true' || (value === 'false' ? false : value);
return {
valid:(typeof value === 'boolean') || value ==='true' || value === 'false',
converted:converted
};
},
imageKey = function(imgsrc){
return (`${imgsrc}`.match(/^.*\/((?:images|marketplace)\/[^/]*\/[^/]*)\/.*$/)||[])[1];
},
newAdds={},
//All the main functions sit inside a module object so that I can
//wrap them for log tracing purposes
module = {
handleInput: function(msg) {
if (msg.type !== "api" ) {
return;
}
try {
var args = msg.content.split(/\s+--/);
var command = args.shift();
switch(command) {
case '!dl-link':
this.link(this.processSelection(msg, {
graphic: {min:1, max:1},
path: {min:1, max:Infinity}
}), this.makeOpts(args, this.templateOptionsSpec));
break;
case '!dl-detach':
this.detach(this.processSelection(msg, {
graphic: {min:1, max:Infinity}
}).graphic);
break;
case '!dl-door':
var objects = this.processSelection(msg, {
graphic: {min:1, max:1},
path: {min: 0, max:1}
});
this.makeDoor(objects.graphic, objects.path, this.makeOpts(args, this.templateOptionsSpec));
break;
case '!dl-directDoor':
this.makeDirectDoor(this.processSelection(msg, {
graphic: {min:1, max:1}
}).graphic, this.makeOpts(args, this.templateOptionsSpec));
break;
case '!dl-wipe':
this.wipe(this.processSelection(msg, {
graphic: {min:0, max:Infinity}
}).graphic, this.makeOpts(args, this.wipeOptionsSpec));
break;
case '!dl-import':
if(!_.isEmpty(args)) {
logger.info(args);
var overwrite = (args[0] === 'overwrite');
args = overwrite ? args.slice(1) : args;
if(!_.isEmpty(args)) {
//Just in case the import string happens to contain a -- that has
//been accidentially split :-(
this.importTileTemplates(args.join(' --'), overwrite);
return;
}
}
report('No import JSON specified');
break;
case '!dl-export':
this.export(this.processSelection(msg, {
graphic: {min:0, max:Infinity}
}).graphic);
break;
case '!dl-redraw':
this.redraw(this.processSelection(msg, {
graphic: {min:0, max:Infinity}
}).graphic);
break;
case '!dl-config':
this.configure(this.makeOpts(args, this.configOptionsSpec));
break;
case '!dl-tmFixup':
this.transmogrifierFixup();
break;
case '!dl-loglevel':
if(args.length){
let level=`${args[0]}`.toUpperCase(),
logLevels=['OFF','ERROR','WARN','INFO','DEBUG','TRACE'];
if(_.contains(logLevels,level)){
myState.config.logLevel= level;
} else {
report(`Valid log levels are: ${logLevels.join(', ')}`);
}
}
report(`loglevel at ${myState.config.logLevel}`);
break;
default:
if(command.indexOf('!dl') === 0) {
if(command.length > 20) {
command = command.slice(0,18) + '...';
}
report('Unrecognised command: "' + command + '". Maybe you forgot a "--"?');
}
}
}
catch(e) {
if (typeof e === 'string') {
report('An error occurred: ' + e);
logger.error("Error: $$$", e);
}
else {
logger.error('Error: ' + e.toString());
report('An error occurred. Please see the log for more details.');
}
}
finally {
logger.prefixString = '';
}
},
wipeOptionsSpec: {
confirm: booleanValidator
},
configOptionsSpec: {
logLevel: function(value) {
var converted = value.toUpperCase();
return {valid:_.has(logger, converted), converted:converted};
},
autoLink: booleanValidator
},
templateOptionsSpec: {
local:booleanValidator,
overwrite:booleanValidator
},
makeOpts: function(args, spec) {
return _.reduce(args, function(options, arg) {
var parts = arg.split(/\s+/);
if (parts.length <= 2) {
//Allow for bare switches
var value = parts.length == 2 ? parts[1] : true;
var validator = spec[parts[0]];
if (validator) {
var result = validator(value);
if (result.valid) {
options[parts[0]] = result.converted;
return options;
}
}
}
logger.error('Unrecognised or poorly formed option [$$$]', arg);
report('ERROR: unrecognised or poorly formed option --' + arg + '');
return options;
}, {});
},
processSelection: function(msg, constraints) {
var selection = msg.selected ? msg.selected : [];
return _.reduce(constraints, function(result, constraintDetails, type) {
var objects = _.chain(selection)
.where({_type: type})
.map(function(selected) {
return getObj(selected._type, selected._id);
})
.compact()
.value();
if (_.size(objects) < constraintDetails.min || _.size(objects) > constraintDetails.max) {
throw ('Wrong number of objects of type [' + type + '] selected, should be between ' + constraintDetails.min + ' and ' + constraintDetails.max);
}
switch(_.size(objects)) {
case 0:
break;
case 1:
if (constraintDetails.max === 1) {
result[type] = objects[0];
}
else {
result[type] = objects;
}
break;
default:
result[type] = objects;
}
return result;
}, {});
},
/////////////////////////////////////////
// Command handlers
/////////////////////////////////////////
configure: function(options) {
_.each(options, function(value, key) {
logger.info('Setting configuration option $$$ to $$$',key, value);
myState.config[key] = value;
});
report('Configuration is now: ' + JSON.stringify(myState.config));
},
wipe: function(graphics, options) {
var module = this;
if (graphics) {
var results = {
global:0,
local:0
};
_.each(graphics, function(graphic) {
var controlInfo = module.getControlInfoObject(graphic);
controlInfo.wipeTemplate(results);
});
report('Wiped ' + results.local + ' local templates and ' + results.global + ' global templates');
}
else if (!options.confirm){
report('You are about to wipe all of your stored templates, are you *really* sure you want to do this? [Confirm](!dl-wipe --confirm)');
}
else {
var removeCount = this.globalTemplateStorage().removeAll();
report('' + removeCount + ' global dynamic lighting templates have been wiped');
}
},
export: function(graphics) {
/* var module = this;*/
var exportObject = {
version: schemaVersion,
templates: this.globalTemplateStorage().load(graphics)
};
report(JSON.stringify(exportObject));
},
redraw: function(objects) {
var module = this;
if (!_.isEmpty(objects)) {
_.chain(objects)
.filter(function(object) {
return module.tokenStorage(object).get('template');
})
.each(function(tile) {
module.handleTokenChange(tile);
});
}
else {
_.chain(module.globalTemplateStorage().load())
.map(function(template) {
return findObjs({_type: 'graphic', imgsrc:template.imgsrc, _subtype:'token'});
})
.flatten()
.each(function(graphic) {
module.handleTokenChange(graphic);
});
}
},
importTileTemplates: function(jsonString, overwrite) {
try {
var importObject = JSON.parse(jsonString);
if (!importObject.version || importObject.version !== schemaVersion) {
report('Imported templates were generated with schema version ['
+ importObject.version + '] which is not the same as script schema version ['
+ schemaVersion + ']');
return;
}
var importCount = this.globalTemplateStorage().importTemplates(importObject.templates);
var message = '
'
+'
Import completed
' +
'
Total templates in import: ' + _.size(importObject.templates) + '
';
if (!overwrite) {
var skipCount = _.size(importObject.templates) - importCount;
message += '
Skipped ' + skipCount + ' templates for tiles which already have templates. '
+ 'Rerun with --overwrite to replace these with the imported tiles. '
+ ' See log for more details.
';
}
message += '
';
report(message);
}
catch(e) {
logger.error(e.toString());
report('There was an error trying to read the supplied JSON text, see the log for more details');
}
},
link: function(selection, options) {
var tile = selection.graphic;
if (tile.get('_subtype') !== 'token' || tile.get('layer') !== 'map') {
report('Selected tile must be on the map layer.');
return;
}
var template = this.buildTemplate(tile, selection.path, options, '!dl-link');
if (!template) return;
this.getControlInfoObject(tile, _.extend(options, {template: template})).onAdded();
report("DL paths successfully linked for map tile");
},
//Make a normal door with a transparent control token
makeDoor: function(doorToken, doorBoundsPath, options) {
var doorBoundingBox = doorBoundsPath ? this.makeBoundingBox(doorBoundsPath) : this.makeBoundingBox(doorToken);
var template = this.makeDoorTemplate(doorToken, doorBoundingBox, options, '!dl-door');
if(!template) return;
var hinge = [doorBoundingBox.left - (doorBoundingBox.width/2), doorBoundingBox.top];
var hingeOffset = [hinge[0] - doorToken.get('left'), hinge[1] - doorToken.get('top')];
template.doorDetails.type = 'indirect';
template.doorDetails.offset = hingeOffset;
doorToken.set('layer', 'map');
this.getControlInfoObject(doorToken, _.extend(options, {template: template})).onAdded();
if (doorBoundsPath) {
doorBoundsPath.remove();
}
report('Door created successfully');
},
makeDirectDoor: function(token, options) {
var doorBoundingBox = {
left: token.get('left') + (token.get('width')/4),
top: token.get('top'),
width: token.get('width')/2,
height: token.get('height')
};
var template = this.makeDoorTemplate(token, doorBoundingBox, options, '!dl-directDoor');
if (!template) return;
template.doorDetails.type = 'direct';
this.getControlInfoObject(token, _.extend(options, {template: template})).onAdded();
report('Direct control door created successfully');
},
detach: function(graphics) {
var module = this;
_.chain(graphics)
.map(function(graphic) {
return module.getControlInfoObject(graphic);
})
.invoke('detach');
report('Detach complete');
},
transmogrifierFixup: function() {
var module = this;
var graphicCount = 0;
_.chain(findObjs({_type:'path', layer:'walls'}))
.map(function(path) {
var graphicId = path.get('controlledby');
if(graphicId) {
var graphic = getObj('graphic', graphicId);
if (graphic) {
if(!_.contains(module.tokenStorage(graphic).get('dlPaths'), path.id)) {
path.remove();
return graphic;
}
}
}
})
.uniq()
.compact()
.each(function(graphic) {
graphicCount++;
module.getControlInfoObject(graphic).onChange();
});
report('Deleted orphaned DL paths for ' + graphicCount + ' graphics and redrew.');
},
/////////////////////////////////////////////////
// Event Handlers
/////////////////////////////////////////////////
handleNewToken: function(token) {
newAdds[token.id]=true;
_.delay(()=>(delete newAdds[token.id]),200);
logger.debug('Got new token $$$', token);
if(token.get('name') === 'DynamicLightRecorder') {
//This can only happen when this token is being added by the transmogrifier, AFAICT
//In any case, if the token is already set up, we have nothing to do
return;
}
if (myState.config.autoLink) {
this.getControlInfoObject(token).onAdded();
}
},
handleTokenChange: function(token, previous) {
logger.debug('Changed token $$$ from $$$', token, previous);
if(_.isEqual(_.omit(token.attributes, '_fbpath'), previous) || token.get('gmnotes') === 'APICREATE' || newAdds[token.id]) {
//this is a spurious change event generated by the API creating an object,
//we can safely ignore it.
return;
}
newAdds[token.id]=true;
_.delay(()=>(delete newAdds[token.id]),200);
if (previous && previous.gmnotes.indexOf('DynamicLightData') === 0 && previous.gmnotes !== token.get('gmnotes')) {
logger.debug('Resetting changed gmnotes');
token.set('gmnotes', previous.gmnotes);
}
this.getControlInfoObject(token).onChange(previous);
},
handleDeleteToken: function(token) {
/* var controlInfo = */ this.getControlInfoObject(token).onDelete();
},
handlePathChange: function(path, previous) {
if (previous.controlledby && previous.layer === 'walls') {
var graphic = getObj('graphic', previous.controlledby);
if (graphic && graphic.get('name') === 'DynamicLightRecorder') {
path.set('top', previous.top);
path.set('left', previous.left);
path.set('width', previous.width);
path.set('height', previous.height);
path.set('scaleX', previous.scaleX);
path.set('scaleY', previous.scaleY);
path.set('rotation', previous.rotation);
path.set('controlledby', previous.controlledby);
}
}
},
///////////////////////////////////////////////
//Template handling
///////////////////////////////////////////////
//Make a DL template for a tile
//using a set of paths
buildTemplate: function(tile, paths, options, cmd) {
if (tile.get('imgsrc') === clearURL) {
report("You are trying to define a template for the clear placeholder image. This isn't a good plan.");
return;
}
if (this.globalTemplateStorage().load(tile) && !options.overwrite && !options.local) {
report('Tile already has a global template defined. Do you want to [Overwrite](' + cmd + ' --overwrite) it?');
return;
}
else if(this.tokenStorage(tile).get('template', true) && !options.overwrite && options.local) {
report('Tile already has a local template defined. Do you want to [Overwrite](' + cmd + ' --overwrite --local) it?');
return;
}
var template = {
imgsrc : tile.get('imgsrc'),
top: tile.get('top'),
left: tile.get('left'),
width: tile.get('width'),
height: tile.get('height'),
flipv: tile.get('flipv'),
fliph: tile.get('fliph'),
rotation: tile.get('rotation') % 360,
paths: _.map(paths, function(path) {
return {
path: path.get('_path'),
offsetY: path.get('top') - tile.get('top'),
offsetX: path.get('left') - tile.get('left'),
width: path.get('width'),
height: path.get('height'),
stroke_width: 1,
layer: 'walls'
};
})
};
logger.debug('Created new template $$$', template);
//These will get redrawn on the walls layer later.
_.invoke(paths, 'remove');
return template;
},
/////////////////////////////////////////
//Door templates
/////////////////////////////////////////
makeBoundingBox: function(object) {
return {
left: object.get('left'),
top: object.get('top'),
width: object.get('width'),
height: object.get('height')
};
},
makeDoorTemplate: function(token, doorBoundingBox, options, cmd) {
var doorWidth = doorBoundingBox.width;
var dlLineWidth = doorWidth + 4;
var dlPath = createObj('path', {
pageid: token.get('_pageid'),
layer: 'walls',
stroke_width: 1,
top: doorBoundingBox.top,
left: doorBoundingBox.left,
width: dlLineWidth,
height: 1,
path: '[["M",0,0],["L",' + dlLineWidth + ',0]]'
});
var template = this.buildTemplate(token, [dlPath], options, cmd);
if (!template) {
dlPath.remove();
return;
}
var minRotation = mod(+(token.get('bar1_value') || -90) + template.rotation, 360);
var maxRotation = mod(+(token.get('bar1_max') || 90) + template.rotation, 360);
token.set('bar1_value', '');
token.set('bar1_max', '');
template.doorDetails = {minRotation: minRotation, maxRotation:maxRotation};
return template;
},
///////////////////////////////////////////////
//A control info object wraps a token
//and abstracts away all the handling of
//interrelated controls (for doors) and the relationship
//to a set of DL paths.
////////////////////////////////////////////////
getControlInfoObject: function(token, options) {
options = options || {};
var storage = this.tokenStorage(token);
_.defaults(options, {
doorControl:storage.get('doorControl'),
door:storage.get('door'),
type:storage.get('type')
});
if(options.template) {
options.type = this.getTypeFromTemplate(options.template);
}
switch (options.type) {
case 'directDoorPlaceholder':
if (options.doorControl) {
storage.setTemplateStorage(this.tokenStorage(options.doorControl));
}
break;
case 'doorControl':
if(options.door) {
storage.setTemplateStorage(this.tokenStorage(options.door));
}
break;
default:
//leave as supplied token
}
if(options.local) {
logger.debug('Using local template storage');
storage.detachTemplate();
}
//Perhaps we can work out the type from a global template
//now that we have wired up the storage to delegate to it
//But if autoLinking is turned off we shouldn't do this as
//this could be a newly dropped tile that we're supposed to be
//ignoring
if(!options.type && myState.config.autoLink) {
options.type = this.getTypeFromTemplate(storage.get('template'));
}
//Only set values in the token storage if this is a token
//we recognise or have been given a new type for, otherwise
//we might end up spamming random other tokens with our GM notes!
if(options.type) {
_.each(options, function(value, key) {
if(value){
storage.set(key, value);
}
});
}
return this.makeControlInfoObject(token, storage);
},
getTypeFromTemplate: function(template) {
if (!template) return undefined;
return template.doorDetails ? ((template.doorDetails.type === 'direct') ? 'directDoor' : 'indirectDoor') : 'mapTile';
},
getDummyControlInfo: function(token) {
return {
onChange:function(){},
onDelete:function(){},
onAdded:function(){},
updateDoorControl:function(){},
wipeTemplate:function(){},
detach:function(){},
getImageURL: function(){ return ''; },
logWrap: false,
toJSON: function() {
return "Dummy controlInfo object for token " + (token && token.id);
}
};
},
////////////////////////////////////////////////////
// This is the beast method that actually defines the
// core of the control info object, along with all of
// its functions. There's a bunch of internal functions
// first, and then the public interface at the bottom
////////////////////////////////////////////////////
makeControlInfoObject: function(token, tokenStorage) {
if (!_.contains(['directDoorPlaceholder','directDoor', 'indirectDoor', 'doorControl', 'mapTile'], tokenStorage.get('type'))) {
//This isn't a token we manage, return a null object for it
return this.getDummyControlInfo(token);
}
var module = this,
type = tokenStorage.get('type'),
updateDLPaths = function(templatePaths) {
var old = tokenStorage.get('dlPaths');
tokenStorage.set('dlPaths', _.map(templatePaths, function(path) {
return path.draw(token);
}));
_.invoke(old, 'remove');
},
moveDoorControl = function(hingeOffset) {
logger.debug('Moving door control to offset $$$ from token centre: [$$$,$$$]', hingeOffset, token.get('left'), token.get('top'));
var control = tokenStorage.get('doorControl');
var type = tokenStorage.get('type');
if(!control) {
//This shouldn't happen, but if it does we can fix it
//in the case of an indirectDoor
logger.error('Door control somehow deleted for map token $$$. Will attempt to recreate.', token.id);
if(type === 'indirectDoor') {
tokenStorage.set('doorControl', module.makeDoorControl(token, hingeOffset));
}
return;
}
control.set('rotation', token.get('rotation'));
if(type =='directDoorPlaceholder') {
//Move control token to match placeholder - they are
//the same size and shape so this is easy
control.set('top', token.get('top'));
control.set('left', token.get('left'));
control.set('width', token.get('width'));
control.set('height', token.get('height'));
control.set('fliph', token.get('fliph'));
control.set('flipv', token.get('flipv'));
//Redraw DLpaths
module.getControlInfoObject(control).onChange();
}
else {
control.set('top', token.get('top') + hingeOffset.y());
control.set('left', token.get('left') + hingeOffset.x());
}
},
undoMove = function(previous) {
if (token.get('controlledby') === 'JUST_ADDED') {
token.set('controlledby', '');
return;
}
logger.debug('Undoing move of token $$$, setting back to $$$', token,previous);
token.set('top', previous.top);
token.set('left', previous.left);
token.set('width', previous.width);
token.set('height', previous.height);
token.set('fliph', previous.fliph);
token.set('flipv', previous.flipv);
},
rotateDoor = function(templateWrapper) {
var door = tokenStorage.get('door');
var rotationLimits = templateWrapper.getDoorRotationLimits(door);
var controlRotation = token.get('rotation') % 360;
controlRotation = module.limitAngle(controlRotation, rotationLimits[0], rotationLimits[1]);
token.set('rotation', controlRotation);
var doorOffset = templateWrapper.getNewDoorOffset(token);
logger.debug('Moving door to offset : $$$', doorOffset);
door.set('rotation', token.get('rotation'));
logger.debug('Setting new door position to [$$$,$$$]', token.get('left') + doorOffset.x(), token.get('top') + doorOffset.y());
door.set('left', token.get('left') + doorOffset.x());
door.set('top', token.get('top') + doorOffset.y());
module.getControlInfoObject(door).onChange();
},
removeDependentObject = function(name) {
logger.debug('Removing dependent object: $$$', name);
var object = tokenStorage.get(name);
if (object) {
object.set('controlledby', 'APIREMOVE');
object.remove();
object.remove(name);
}
},
detach = function() {
tokenStorage.detachTemplate();
},
onChange = function(previous) {
var tw = module.getTemplateWrapper(tokenStorage.get('template'));
if (!tw) {
//this token is no longer attached to any template,
//make sure we clean up any dependencies
_.invoke(tokenStorage.get('dlPaths'), 'remove');
tokenStorage.remove('dlPaths');
switch(tokenStorage.get('type')) {
case 'directDoor':
removeDependentObject('door');
break;
case 'indirectDoor':
removeDependentObject('doorControl');
break;
}
return;
}
var transformations = tw.getTransformations(token);
switch(type) {
case 'directDoor':
//Either this an internal trigger from the placeholder being
//created or moved, or it's the first move immediately after placement
//thanks to grid snapping. In either case we should process the move and
//update the DL paths. Otherwise it's the user moving the door control on
//the tokens layer, so we snap it back to match the map layer graphic.
if(!previous || previous.controlledby === 'JUST_ADDED') {
updateDLPaths(_.reduce(transformations, function(result, transformation) {
return _.map(result, transformation);
}, tw.getDLTemplatePaths()));
}
//Drop through
/*eslint-diable no-fallthrough */
case 'doorControl':
/*eslint-enable no-fallthrough */
logger.debug('Door control has been moved');
if(previous) {
undoMove(previous);
rotateDoor(tw);
}
break;
case 'indirectDoor':
moveDoorControl(_.reduce(transformations, function(result, transformation) {
return transformation(result);
}, tw.getHingeOffset()));
updateDLPaths(_.reduce(transformations, function(result, transformation) {
return _.map(result, transformation);
}, tw.getDLTemplatePaths()));
break;
case 'directDoorPlaceholder':
logger.debug('Door has been moved');
moveDoorControl(_.reduce(transformations, function(result, transformation) {
return transformation(result);
}, tw.getHingeOffset()));
if (previous) { //User initiated move of the token itself rather than update from control moving etc
//Record the current rotation as the base value from which limits
//are applied.
token.set('bar1_value', token.get('rotation') % 360);
}
break;
case 'mapTile':
updateDLPaths(_.reduce(transformations, function(result, transformation) {
return _.map(result, transformation);
}, tw.getDLTemplatePaths()));
}
},
onDelete = function() {
var door = tokenStorage.get('door') /*,
doorControl = tokenStorage.get('doorControl') */ ;
switch(type) {
case 'directDoor':
if (token.get('controlledby') === 'APIREMOVE') {
_.invoke(tokenStorage.get('dlPaths'), 'remove');
tokenStorage.remove('dlPaths');
}
else {
//This is a real problem, we can't redraw it because of Roll20 imgsrc restrictions,
//for the time being we'll just leave everything as it is with the placeholder and
//the DLPaths. Perhaps consider moving to the token layer to highlight?
logger.warn("Direct door control with id $$$ has been deleted, can't recreate", token.id);
}
break;
case 'doorControl':
if(door && token.get('controlledby') !== 'APIREMOVE') {
//Force a redraw, we don't want this deleted!
module.getControlInfoObject(door).onAdded();
}
break;
case 'directDoorPlaceholder':
if (token.get('controlledby') !== 'APIREMOVE') {
removeDependentObject('doorControl');
}
break;
case 'indirectDoor':
removeDependentObject('doorControl');
//Intentional drop through
/*eslint-diable no-fallthrough */
case 'mapTile':
/*eslint-enable no-fallthrough */
_.invoke(tokenStorage.get('dlPaths'), 'remove');
tokenStorage.remove('dlPaths');
}
},
onAdded = function() {
var tw = module.getTemplateWrapper(tokenStorage.get('template'));
if (!tw) return;
token.set('name', 'DynamicLightRecorder');
switch(tokenStorage.get('type')) {
case 'directDoor':
//Tidy up any previous placeholder
removeDependentObject('door');
var placeholder = module.makePlaceholder(token);
token.set('layer', 'objects');
token.set('aura1_radius', 0.1);
token.set('isdrawing', 1);
token.set('controlledby', 'JUST_ADDED');
tokenStorage.set('door', placeholder);
onChange();
break;
case 'indirectDoor':
//Remove previous door control if any
removeDependentObject('doorControl');
var control = module.makeDoorControl(token, tw.getHingeOffset());
tokenStorage.set('doorControl', control);
//Drop through.
/*eslint-diable no-fallthrough */
case 'mapTile':
/*eslint-enable no-fallthrough */
onChange();
}
},
wipeTemplate = function(results) {
tokenStorage.wipeTemplate(results);
};
return {
onChange: onChange,
onDelete: onDelete,
onAdded: onAdded,
wipeTemplate: wipeTemplate,
detach: detach,
getImageURL: function() { var template = tokenStorage.get('template'); return template && template.imgsrc; },
logWrap: 'controlInfoObject',
toJSON: function() {
return tokenStorage.toJSON();
}
};
},
makePlaceholder: function(token) {
var placeholder = createObj('graphic', {
imgsrc: clearURL,
subtype: 'token',
pageid: token.get('_pageid'),
layer: 'map',
playersedit_name: false,
playersedit_bar1: false,
playersedit_bar2: false,
playersedit_bar3: false,
name:'DynamicLightRecorder',
gmnotes:'APICREATE',
rotation:token.get('rotation'),
isdrawing:1,
top:token.get('top'),
left: token.get('left'),
width: token.get('width'),
height: token.get('height'),
bar1_value: token.get('rotation') % 360
});
var storage = this.tokenStorage(placeholder);
storage.set('type', 'directDoorPlaceholder');
storage.set('doorControl', token);
return placeholder;
},
makeDoorControl: function(token, offset) {
var control = createObj('graphic', {
imgsrc: clearURL,
subtype: 'token',
pageid: token.get('_pageid'),
layer: 'objects',
name:'DynamicLightRecorder',
playersedit_name: false,
playersedit_bar1: false,
playersedit_bar2: false,
playersedit_bar3: false,
aura1_radius: 0.1,
rotation:token.get('rotation'),
gmnotes:'APICREATE',
isdrawing:1,
top: token.get('top') + offset.y(),
left: token.get('left') + offset.x(),
width: 70,
height: 70
});
var storage = this.tokenStorage(control);
storage.set('type', 'doorControl');
storage.set('door', token);
return control;
},
globalTemplateStorage: function() {
var module = this;
return {
save: function(template) {
logger.debug('saving template $$$ to state storage', template);
let imgkey=imageKey(template.imgsrc);
myState.tileTemplates[imgkey] = template;
},
load: function(key) {
if (!key) {
return _.clone(myState.tileTemplates);
}
if (_.isArray(key)) {
return _.chain(key)
.map(this.load.bind(this))
.compact()
.value();
}
var imgsrc = (typeof key === 'object') ? module.getControlInfoObject(key).getImageURL() : key;
let imgkey=imageKey(imgsrc);
logger.debug('Retrieving template $$$ from state storage', myState.tileTemplates[imgkey], imgsrc);
return myState.tileTemplates[imgkey];
},
remove: function(imgsrc) {
let imgkey=imageKey(imgsrc);
if (myState.tileTemplates[imgkey]) {
delete myState.tileTemplates[imgkey];
_.each(findObjs({_type:'graphic', imgsrc:imgsrc}), function(graphic) {
module.getControlInfoObject(graphic).onChange();
});
return true;
}
return false;
},
removeAll: function() {
var removeCount = _.size(myState.tileTemplates);
_.chain(myState.tileTemplates)
.keys()
.each(this.remove);
return removeCount;
},
importTemplates: function(templates, overwrite) {
var overlapKeys = _.chain(templates)
.keys()
.intersection(_.keys(myState.tileTemplates))
.value();
if (overwrite) {
_.extend(myState.tileTemplates, templates);
return _.size(templates);
}
else {
_.extend(myState.tileTemplates, _.omit(templates, overlapKeys));
logger.info("Skipped template image URLs: $$$", overlapKeys);
return _.size(templates) - _.size(overlapKeys);
}
},
asTokenStorageFor: function(token) {
var global = this,
imgsrc = token.get('imgsrc');
return {
set: function(key, template) {
if (key === 'template') {
if (imgsrc === template.imgsrc) {
return global.save(template);
}
else {
logger.error('Attempting to save template against a token with non-matching imgsrc. Token imgsrc: $$$, template: $$$',
imgsrc,
template);
}
}
else {
logger.warn('templateStorage wrapper for token $$$ asked to save non-template key $$$', token.id, key);
}
},
get: function(key) {
if (key === 'template') {
return global.load(imgsrc);
}
else {
logger.warn('templateStorage wrapper for token $$$ asked to get non-template key $$$', token.id, key);
}
},
remove: function(key) {
if (key === 'template') {
return global.remove(imgsrc);
}
},
wipeTemplate: function(results) {
global.remove(imgsrc) && results.global++;
},
detachTemplate: function(callback) {
callback && callback(global.load(imgsrc));
},
logWrap: 'templateStorageWrapper',
toJSON: function() { return 'templateStorageWrapper for token with id ' + token.id; }
};
},
logWrap: 'globalTemplateStorage',
toJSON: function() { return myState.tileTemplates; }
};
},
tokenStorage: function(token) {
var properties = {
template: _.identity,
doorControl: getObjectMapper('graphic', function(id) {
logger.warn('Warning, control with id [$$$] that should have been attached to token $$$ was not present.', id, token);
}),
door: getObjectMapper('graphic', function(id) {
logger.warn('Warning, door with id [$$$] that should have been attached to token $$$ was not present.', id, token);
}),
dlPaths: function(dlPaths) {
return _.chain(dlPaths)
.map(getObjectMapper('path', function(pathId) {
logger.warn('Warning, path with id [$$$] that should have been attached to token $$$ was not present.', pathId, token);
}))
.compact()
.value();
},
type: _.identity
};
var noWarnKeys = ['local', 'overwrite'];
var gmn = token.get('gmnotes'),
data = {},
module=this,
templateStorage,
previousTemplateStorage,
persist = function() {
var string = JSON.stringify(data, function(key, value) {
if (typeof value === 'object' && _.isEmpty(value)) return undefined;
if (key === 'dlPaths') {
return _.chain(value).compact().pluck('id').value();
}
if (_.contains(['door', 'doorControl'], key) && value !== null) {
return value._id || value.id;
}
return value;
});
logger.debug("Persisting token info $$$ on token with id $$$ as string $$$", data, token.id, string);
token.set('gmnotes', 'DynamicLightData:' + string);
},
storage = {
set: function(key, value) {
if (!properties[key]) {
if (!_.contains(noWarnKeys, key)) {
logger.warn('Unrecognised token property key $$$, ignoring', key);
}
return;
}
if(key === 'template' && templateStorage) {
return templateStorage.set(key, value);
}
if (value !== data[key]) {
data[key] = value;
persist();
}
},
get: function(key, localOnly) {
if (key === 'template' && templateStorage && !localOnly) {
return templateStorage.get(key);
}
return data[key];
},
remove: function(key) {
if (key === 'template' && templateStorage) {
return templateStorage.remove(key);
}
if (data[key]) {
delete data[key];
persist();
return true;
}
return false;
},
setTemplateStorage: function(newTemplateStorage) {
previousTemplateStorage = templateStorage;
templateStorage = newTemplateStorage;
//If we are delegating to different template storage
//then we should wipe our own template
if(templateStorage !== undefined) {
data.template = undefined;
persist();
}
},
detachTemplate: function( /* callback */) {
if (templateStorage) {
templateStorage.detachTemplate(function(template) {
data.template = template;
previousTemplateStorage = templateStorage;
templateStorage = undefined;
persist();
});
}
},
wipeTemplate: function(results) {
if(!templateStorage) {
logger.debug('Wiping local template $$$ and resetting template storage to $$$', data.template, previousTemplateStorage);
data.template = undefined;
results.local++;
templateStorage = previousTemplateStorage;
persist();
module.getControlInfoObject(token).onChange();
}
else {
templateStorage.wipeTemplate(results);
}
},
logWrap:'tokenTemplateStorage',
toJSON: function() {
return data;
}
};
var changedValues = false;
if (gmn.indexOf('DynamicLightData:') === 0) {
data = _.chain(JSON.parse(gmn.slice('DynamicLightData:'.length)))
.pick(_.functions(properties))
.reduce(function(result, value, key) {
logger.debug("Transforming parsed GM Notes data, property name : $$$, property value $$$", key, value);
var transformed = properties[key](value);
logger.debug("Transformed value: $$$", transformed);
if (transformed) {
result[key] = transformed;
}
else if(value) {
changedValues = true;
}
return result;
}, {})
.value();
}
templateStorage = data.template ? undefined : this.globalTemplateStorage().asTokenStorageFor(token);
previousTemplateStorage = data.template ? this.globalTemplateStorage().asTokenStorageFor(token) : undefined;
if (changedValues) {
persist();
}
return storage;
},
getTemplateWrapper: function(template) {
if (!template) return;
var layout = _.chain(template)
.clone()
.pick(['fliph', 'flipv', 'height', 'width', 'top', 'left', 'rotation'])
.value(),
module = this;
var makeTransform = function(spec) {
var func = function(object) {
logger.debug('Performing transformation $$$ on $$$', spec, object);
var result = object[spec.name].apply(object, spec.args);
logger.debug('Transformation result: $$$', result);
return result;
};
func.toJSON = function() { return spec; };
return func;
};
return {
getTransformations: function(token) {
// var transformations = [];
var scaleX = token.get('width') / layout.width;
var scaleY = token.get('height') / layout.height;
var fliph = (token.get('fliph') !== layout.fliph);
var flipv = (token.get('flipv') !== layout.flipv);
var angle = token.get('rotation') - layout.rotation;
return _.map([ {name: 'scale', args: [scaleX, scaleY]},
{name: 'flip', args: [fliph, flipv]},
{name: 'rotate', args:[angle]}
], makeTransform);
},
getNewDoorOffset: function(token) {
return this.getHingeOffset().flipOrigin().rotate(token.get('rotation') - template.rotation);
},
getDoorRotationLimits: function(door) {
var baseRotation = door.get('bar1_value') || template.rotation;
if(template.doorDetails) {
var min = template.doorDetails.minRotation || -180;
var max = template.doorDetails.maxRotation || 180;
return [mod(baseRotation + min,360),
mod(baseRotation + max, 360)];
}
return [0, 360];
},
getDLTemplatePaths: function() {
return _.map(template.paths, function(path) {
return module.path(JSON.parse(path.path), path.offsetX, path.offsetY, path.width, path.height);
});
},
getHingeOffset: function() {
if (template.doorDetails) {
if (template.doorDetails.type === 'direct') {
return module.point(0,0);
}
else {
return module.point(template.doorDetails.offset[0], template.doorDetails.offset[1]);
}
}
logger.error('Requested hingeOffset but no doorDetails on template');
},
logWrap: 'templateWrapper',
toJSON: function() {
return template;
}
};
},
limitAngle: function(angle, min, max) {
var diffMax = mod((max - angle) + 180, 360) - 180;
var diffMin = mod((angle - min) + 180,360) - 180;
logger.debug("DiffMin: $$$, DiffMax: $$$", diffMin, diffMax);
if (diffMax >= 0 && diffMin >= 0) {
return angle;
}
return Math.abs(diffMax) > Math.abs(diffMin) ? min : max;
},
//Immutable point object 'constructor'
point: function(x,y) {
var module = this;
//Already a point object
if (typeof x === 'object' && !_.isArray(x)) {
return x;
}
//Roll20 path point, convert it
else if (_.isArray(x) && _.contains(['L','M'], x[0])) {
return module.point(x[1], x[2]);
}
return {
flip: function(horizontal, vertical, width, height) {
if (!horizontal && !vertical) return this;
width = width || 0;
height = height || 0;
return module.point(horizontal ? width - x : x,
vertical ? height - y : y);
},
rotate: function(angle, centre) {
if (angle % 360 === 0) return this;
centre = centre || [0,0];
angle = angle % 360;
logger.debug('Rotating [$$$,$$$] around $$$ by $$$ degrees', x,y,centre,angle);
var s = Math.sin(angle * Math.PI / 180.0);
var c = Math.cos(angle * Math.PI / 180.0);
// translate point back to origin:
var xTranslated = x - centre[0];
var yTranslated = y - centre[1];
logger.debug('Point translated with centre at origin: [$$$,$$$]', xTranslated, yTranslated);
// rotate point
var xNew = (xTranslated * c) - (yTranslated * s);
var yNew = (xTranslated * s) + (yTranslated * c);
// translate point back:
return module.point(xNew + centre[0], yNew + centre[1]);
},
flipOrigin: function() {
return module.point(-x,-y);
},
scale: function(scaleX, scaleY) {
if (scaleX === 1 && scaleY ===1) return this;
return module.point(x * scaleX, y * scaleY);
},
translate: function(xOffset, yOffset) {
if (xOffset === 0 && yOffset === 0) return this;
return module.point(x+xOffset, y+yOffset);
},
asPathPoint: function(first) {
return [first ? 'M' : 'L', x, y];
},
x: function() {
return x;
},
y: function() {
return y;
},
logWrap: 'point',
toJSON: function() {
return{x:x, y:y};
}
};
},
//Immutable path object 'constructor'
path: function(inPoints, offsetX, offsetY, width, height) {
var module = this;
var points = _.map(inPoints, function(point) {
//Ensure that we have proper point objects, this method
//can be called with roll20 path points as well.
return module.point(point);
});
var offset = module.point(offsetX, offsetY);
return {
rotate: function(angle) {
if (angle % 360 === 0) return this;
var pointsCentre = [width/2, height/2];
var bounds = {
xMax: 0,
yMax: 0,
xMin:Infinity,
yMin:Infinity
};
var newPoints = _.chain(points)
.map(function(myPoint) {
var result = myPoint.rotate(angle, pointsCentre);
bounds.xMax = Math.max(bounds.xMax, result.x());
bounds.yMax = Math.max(bounds.yMax, result.y());
bounds.xMin = Math.min(bounds.xMin, result.x());
bounds.yMin = Math.min(bounds.yMin, result.y());
return result;
})
.map(function(myPoint) {
return myPoint.translate(-bounds.xMin, -bounds.yMin);
})
.value();
var newWidth = bounds.xMax - bounds.xMin;
var newHeight = bounds.yMax - bounds.yMin;
//The bounding box has changed shape, which skews the centre
//away from where it would be if we'd just rotated the whole
//box as was. Allow for this offset.
var newCentreXOffset = (newWidth/2 + bounds.xMin) - pointsCentre[0];
var newCentreYOffset = (newHeight/2 + bounds.yMin) - pointsCentre[1];
var oldCentreRotated = offset.rotate(angle);
var newOffset = oldCentreRotated.translate(newCentreXOffset, newCentreYOffset);
return module.path(newPoints, newOffset.x(), newOffset.y(), newWidth, newHeight);
},
flip: function(horizontal, vertical) {
if (!horizontal && !vertical) return this;
var newPoints = _.map(points, function(point) {
return point.flip(horizontal, vertical, width, height);
});
var newOffset = offset.flip(horizontal, vertical, 0,0);
return module.path(newPoints, newOffset.x(), newOffset.y(), width, height);
},
scale: function(scaleX, scaleY) {
if (scaleX === 1 && scaleY === 1) return this;
var newPoints = _.map(points, function(point) {
return point.scale(scaleX, scaleY);
});
var newOffset = offset.scale(scaleX, scaleY);
return module.path(newPoints, newOffset.x(), newOffset.y(), width * scaleX, height * scaleY);
},
draw:function(relativeToToken) {
var attributes = {
width: width,
height: height,
stroke_width: 1,
layer: 'walls',
pageid: relativeToToken.get('_pageid'),
left: relativeToToken.get('left') + offset.x(),
top: relativeToToken.get('top') + offset.y(),
controlledby: relativeToToken.id, //Help survive the transmogrifier
path: JSON.stringify(_.map(points, function(point, index) {
return point.asPathPoint(index === 0);
}))
};
logger.debug("Drawing path for token $$$ with properties: $$$", relativeToToken, attributes);
return createObj('path', attributes);
},
logWrap: 'path',
toJSON: function() {
return{
points:points,
offset:offset,
width:width,
height:height
};
}
};
},
checkInstall: function() {
var module=this;
logger.info('-=> DynamicLightRecorder v$$$ <=-', version);
if( ! _.has(state,'DynamicLightRecorder') || myState.version !== schemaVersion) {
logger.info(' > Updating Schema to v$$$ from $$$<', schemaVersion, myState && myState.version);
logger.info('Preupgrade state: $$$', myState);
switch(myState && myState.version) {
case 0.1:
_.each(myState.tilePaths, function(tilePath) {
var tileToken = getObj('graphic', tilePath.tileId);
if (tileToken) {
var controlInfo = _.reduce(tilePath.pathIds, function(controlInfo, pathId) {
var path = getObj('path', pathId);
if (path && path !== null) {
controlInfo.dlPaths.push(path);
}
}, { dlPaths: [], doorControl: null});
this.saveControlInfo(tileToken, controlInfo);
}
});
delete myState.tilePaths;
/*eslint-diable no-fallthrough */
case 0.2:
/*eslint-enable no-fallthrough */
myState.doorControls = {};
_.chain(myState.tileTemplates)
.keys()
.map(function(imgsrc) {
return findObjs({_type: 'graphic', imgsrc:imgsrc, layer:'map', _subtype:'token'});
})
.flatten()
.each(function(graphic) {
var cb = graphic.get('controlledby');
if (cb && !_.isEmpty(cb)) {
var paths = _.chain(cb.split(","))
.map(getObjectMapper('path'))
.compact()
.value();
if (!_.isEmpty(paths)) {
this.saveControlInfo(graphic, { dlPaths: paths, doorControl: null});
}
}
});
/*eslint-diable no-fallthrough */
case 0.3:
/*eslint-enable no-fallthrough */
_.each(myState.tileTemplates, function(template) {
if (template.isDoor) {
delete template.isDoor;
var hingeOffset = [-(template.width/2), 0];
template.doorDetails = { type:'indirect', offset:hingeOffset};
}
});
/*eslint-diable no-fallthrough */
case 0.4:
/*eslint-enable no-fallthrough */
myState.config.logLevel = 'INFO';
/*eslint-diable no-fallthrough */
case 0.5:
/*eslint-enable no-fallthrough */
_.each(myState.doorControls, function(doorId, controlId) {
var door = getObj('graphic', doorId);
var control = getObj('graphic', controlId);
if (door && control) {
var type = control.get('imgsrc') === clearURL ? 'doorControl' : 'directDoorl';
control.set('gmnotes'), 'DynamicLightData:' + JSON.stringify({type:type, door:doorId});
}
});
delete myState.doorControls;
_.chain(myState.tileTemplates)
.keys()
.map(function(imgsrc) {
return findObjs({_type:'graphic', layer:'map', imgsrc:imgsrc});
})
.tap(function(tokens) {
tokens.push(findObjs({type:'graphic', layer:'map', imgsrc:clearURL}));
})
.flatten()
.each(function(token) {
var cb = token.get('controlledby');
try {
var controlInfo = JSON.parse(cb);
if (controlInfo && controlInfo.dlPaths) {
var newData = {dlPaths:controlInfo.dlPaths, type:'mapTile'};
if (controlInfo.doorControl) {
newData.type = (token.get('imgsrc') === clearURL) ? 'directDoorPlaceholder' : 'indirectDoor';
newData.doorControl = controlInfo.doorControl;
}
token.set('gmnotes', 'DynamicLightData:' + JSON.stringify(newData));
token.set('controlledby', '');
}
}
/* eslint-disable no-empty */
catch(e) {}
/* eslint-enable no-empty */
});
/*eslint-diable no-fallthrough */
case 0.6:
/*eslint-enable no-fallthrough */
myState.config.autoLink = true;
myState.config.logLevel = 'INFO';
logger.info('Upgrading existing tokens with transmogrifier-protection');
_.each(myState.tileTemplates, function(template, key) {
template.imgsrc = key;
_.each(findObjs({type:'graphic', imgsrc:template.imgsrc}), function(graphic) {
var gmn = graphic.get('gmnotes');
if (gmn.indexOf('DynamicLightData:') === 0) {
graphic.set('name', 'DynamicLightRecorder');
var tokenStorage = module.tokenStorage(graphic);
var dlPaths = tokenStorage.get('dlPaths');
_.each(dlPaths, function(dlPath) {
dlPath.set('controlledby', graphic.id);
});
var doorControl = tokenStorage.get('doorControl');
var door = tokenStorage.get('door');
doorControl && doorControl.set('name', 'DynamicLightRecorder');
door && door.set('name', 'DynamicLightRecorder');
}
});
});
/*eslint-diable no-fallthrough */
case 0.7:{
/*eslint-enable no-fallthrough */
let temps={};
_.each(myState.tileTemplates, function(template,key){
temps[imageKey(key)]=template;
});
myState.tileTemplates=temps;
}
myState.version = schemaVersion;
break;
default:
if (!myState) {
state.DynamicLightRecorder = {
version: schemaVersion,
tileTemplates: {},
config: {
logLevel: 'INFO',
autoLink: true
}
};
myState = state.DynamicLightRecorder;
logger.info('Making new state object $$$', myState);
}
else {
logger.fatal('Unknown schema version for state $$$', myState);
report('Serious error attempting to upgrade your global state, please see log for details. '
+'DynamicLightRecorder will not function correctly until this is fixed');
myState = undefined;
}
break;
}
logger.info('Upgraded state: $$$', myState);
}
},
logWrap: 'module'
};
/////////////////////////////
// General helpers
/////////////////////////////
var report = function(msg) {
//Horrible bug with this at the moment - seems to generate spurious chat
//messages when noarchive:true is set
//sendChat('DynamicLightRecorder', '' + msg, null, {noarchive:true});
sendChat('DynamicLightRecorder', '/w gm ' + msg);
},
getObjectMapper = function(type, notFoundCallback) {
return function(id) {
if (!id) return;
var object = getObj(type, id);
if (!object && (typeof notFoundCallback ==='function')) notFoundCallback(id, type);
return object;
};
},
logger = (function() {
var logger = {
OFF: 0,
ERROR: 1,
WARN:2,
INFO:3,
DEBUG: 4,
TRACE: 5,
prefixString: ''
};
logger.getLabel = function(logLevel) {
var logPair = _.chain(this).pairs().find(function(pair) { return pair[1] === logLevel;}).value();
return logPair ? logPair[0] : 'UNKNOWN';
};
var stringify = function(object) {
if (typeof object === 'undefined') return object;
var result = (typeof object === 'string') ? object : JSON.stringify(object);
if (result) result = result.replace(/"/g, '');
return result;
};
var shouldLog = function(level) {
var logLevel = logger.INFO;
if (myState && myState.config && myState.config.logLevel !== undefined) {
logLevel = logger[myState.config.logLevel];
}
return level <= logLevel;
};
var outputLog = function(level, message) {
if(!shouldLog(level)) return;
var args = arguments.length > 2 ? _.toArray(arguments).slice(2) : [];
message = stringify(message);
if (message) {
message = message.replace(/\$\$\$/g, function() {
return stringify(args.shift());
});
}
log('DynamicLightRecorder ' + Date.now()
+ ' ' + logger.getLabel(level) + ' : '
+ (shouldLog(logger.TRACE) ? logger.prefixString : '')
+ message);
};
_.each(logger, function(level, levelName) {
logger[levelName.toLowerCase()] = _.partial(outputLog.bind(logger), level);
});
logger.wrapModule = function(modToWrap) {
if (shouldLog(logger.TRACE)) {
_.chain(modToWrap)
.functions()
.each(function(funcName){
var origFunc = modToWrap[funcName];
modToWrap[funcName] = logger.wrapFunction(funcName, origFunc, modToWrap.logWrap);
});
modToWrap.isLogWrapped = true;
}
};
logger.wrapFunction = function(name, func, moduleName) {
if (shouldLog(logger.TRACE)) {
if (name === 'toJSON') { return func; }
return function() {
logger.trace('$$$.$$$ starting with args $$$', moduleName, name, arguments);
logger.prefixString = logger.prefixString + ' ';
var retVal = func.apply(this, arguments);
logger.prefixString = logger.prefixString.slice(0, -2);
logger.trace('$$$.$$$ ending with return value $$$', moduleName, name, retVal);
if (retVal && retVal.logWrap && !retVal.isLogWrapped) {
logger.wrapModule(retVal, retVal.logWrap);
}
return retVal;
};
}
return func;
};
return logger;
})(),
registerEventHandlers = function() {
on('chat:message', module.handleInput.bind(module));
on('change:token', module.handleTokenChange.bind(module));
on('change:path', module.handlePathChange.bind(module));
on('add:token', module.handleNewToken.bind(module));
on('destroy:token', module.handleDeleteToken.bind(module));
/*global ChangeTokenImg */
if('undefined' !== typeof ChangeTokenImg && ChangeTokenImg.ObserveTokenChange){
ChangeTokenImg.ObserveTokenChange(module.handleTokenChange.bind(module));
}
/*eslint-disable no-undef */
};
logger.wrapModule(module);
return {
RegisterEventHandlers: registerEventHandlers,
CheckInstall: module.checkInstall.bind(module)
};
}());
on("ready",function(){
'use strict';
DynamicLightRecorder.CheckInstall();
DynamicLightRecorder.RegisterEventHandlers();
});