diff --git a/Animation/Help.txt b/Animation/Help.txt
new file mode 100644
index 0000000000..baac3f2e11
--- /dev/null
+++ b/Animation/Help.txt
@@ -0,0 +1,276 @@
+Animation
+
+Animation allows the user to define and run animations consisting of images from
+a Roll20 user library. Each frame's duration, position, and orientation can be
+set independently of the other frames, as can aura and light properties.
+
+It is recommended that this script be used in conjunction with the CommandShell
+module, which will improve output formatting and command discovery.
+
+
+Commands:
+
+All Animation commands are accessed via the "anim" command, which provides the
+following subcommands (each described in greater detail in its own section):
+
+ !anim help [COMMAND]
+ Display a help message. If COMMAND is specified, the help message will
+ detail the usage of the specified subcommand. Otherwise, the help
+ message will summarize all available subcommands.
+
+ !anim add TYPE [...]
+ Add/name a new image, animation, or frame (as determined by TYPE). See
+ "!anim add" section below for full subcommand details.
+
+ !anim edit TYPE [...]
+ Edit an existing image, animation, or frame (as determined by TYPE). See
+ "!anim edit" section below for full subcommand details.
+
+ !anim remove TYPE [...]
+ Remove an existing image, animation, or frame (as determined by TYPE).
+ See "!anim remove" section below for full subcommand details.
+
+ !anim list TYPE [...]
+ Display information about existing images, animations, or frames (as
+ determined by TYPE). See "!anim list" section below for full subcommand
+ details.
+
+ !anim run NAME [options]
+ Run a specified animation. See "!anim run" section below for full
+ subcommand details.
+
+
+Subcommand Reference:
+
+The "!anim add" subcommand operates in three modes:
+
+ !anim add image NAME [URL]
+ Names the specified image for later reference. If URL is not provided,
+ the image for the currently selected token will be used. In either
+ case, the relevant image must come from a Roll20 user library.
+
+ !anim add animation NAME [CYCLES]
+ Creates an empty animation. If CYCLES is specified, it is the default
+ number of times to cycle through the animation when it is played. If
+ not, the default is 1.
+
+ !anim add frame ANIMATION IMAGE DURATION [options]
+ Adds a frame to the specified animation. IMAGE can be either a URL or
+ the name of an image defined with "!anim add image". DURATION is the
+ amount of time (in milliseconds) that the frame will be displayed when
+ the animation is run.
+
+The "!anim add frame" subcommand accepts the following options:
+
+ -x X, -y Y Frame offset (in pixels) from the animation origin
+ (default: 0, i.e. centered at animation center)
+
+ -w W, --width W Frame width in pixels (default: 70, i.e. 1 square)
+ -h H, --height H Frame height in pixels (default: 70, i.e. 1 square)
+
+ -r A, --rotation A Frame rotation in degrees, relative to the animation's
+ orientation (default: 0, i.e. aligned with animation)
+
+ -a R, --aura R Radius (in page units) of frame's aura (default: none)
+ --auracolor C Color of frame's aura (default: #FFFF99)
+ --aurasquare Make frame's aura square (default: circle)
+
+ -t C, --tint C Color tint to be applied to frame (default: none)
+
+ -l R, --light R Radius (in page units) of light emitted by frame
+ (default: none)
+ -d R, --dim R Radius (in page units) at which frame's light begins to
+ dim (default: none)
+ --lightangle A Angle (in degrees) of light emitted by frame
+ (default: 360)
+
+ -i I, --insert I Index (0-based) at which to insert new frame
+ (default: after last frame)
+
+ -C I, --copy I Index (0-based) of frame to copy. Any arguments which
+ set frame properties will override the copied properties
+ (for example, spinning an image can be accomplished by
+ creating a first frame, then repeatedly calling
+ "!anim add frame ANIMATION -C -1 -r A", where A is
+ a number which is increased or decreased with each
+ call). If I is negative, it is relative to the
+ insertion index (so "-C -1" will copy the frame just
+ before the new frame). In this form of the command, the
+ IMAGE and DURATION arguments are optional.
+
+ -I IMG, --image IMG As IMAGE argument, for use with --copy.
+ -D T, --duration T As DURATION argument, for use with --copy.
+
+
+The "!anim edit" subcommand operates in three modes:
+
+ !anim edit image NAME [URL]
+ Replaces the specified named image with a new image. If URL is not
+ provided, the image for the currently selected token will be used. In
+ either case, the relevant image must come from a Roll20 user library.
+
+ !anim edit animation NAME CYCLES
+ Sets the default number of times to cycle through the specified
+ animation when it is played.
+
+ !anim edit frame ANIMATION FRAME_INDEX [options]
+ Modifies a frame of the specified animation. FRAME_INDEX is the 0-based
+ index of the frame to modify.
+
+The "!anim edit frame" subcommand accepts the following options:
+
+ -x X, -y Y Frame offset (in pixels) from the animation origin
+
+ -w W, --width W Frame width in pixels
+ -h H, --height H Frame height in pixels
+
+ -r A, --rotation A Frame rotation in degrees, relative to the animation's
+ orientation
+
+ -a R, --aura R Radius (in page units) of frame's aura
+ --auracolor C Color of frame's aura
+ --aurasquare Make frame's aura square
+
+ -t C, --tint C Color tint to be applied to frame
+
+ -l R, --light R Radius (in page units) of light emitted by frame
+ -d R, --dim R Radius (in page units) at which frame's light begins to
+ dim
+ --lightangle A Angle (in degrees) of light emitted by frame
+
+ -I IMG, --image IMG Name or URL of frame image
+
+ -D T, --duration T Number of milliseconds to show frame
+
+
+The "!anim remove" subcommand operates in three modes:
+
+ !anim remove image NAME
+ Removes the specified image.
+
+ !anim remove animation NAME
+ Removes the specified animation.
+
+ !anim remove frame ANIMATION FRAME_INDEX
+ Removes a frame from the specified animation. FRAME_INDEX is the
+ 0-based index of the frame to remove.
+
+
+The "!anim list" subcommand operates in three modes:
+
+ !anim list image [NAME]
+ If NAME is provided, will display the URL of the specified image.
+ Otherwise, will list the names of all named images.
+
+ !anim list animation [NAME]
+ If NAME is provided, will display summary info about the specified
+ animation. Otherwise, will list the name and duration of each stored
+ animation.
+
+ !anim list frame ANIMATION [FRAME_INDEX]
+ If FRAME_INDEX is provided, will display all properties of the specified
+ frame of the specified animation. Otherwise, will list all the frames
+ of the specified animation.
+
+
+The "!anim run" command displays an animation:
+
+ !anim run NAME [options]
+
+The "!anim run" subcommand accepts the following options:
+
+ -x X, -y Y Coordinates (in pixels) at which to display the
+ specified animation. If either is not specified, the
+ corresponding coordinate of the currently selected
+ token will be used. If no token is used, the midpoint
+ of the page will be used.
+
+ -r A, --rotation A Rotation (in degrees) at which to display the animation
+ (default: 0, i.e. do not rotate)
+
+ -f F, --timefactor F Scale factor for frame durations (default: 1)
+
+ --xscale F Size scale factor in the X direction, affecting frame
+ sizes, offsets, aura radii, and light radii (default: 1)
+ --yscale F Size scale factor in the Y direction, affecting frame
+ sizes, offsets, aura radii, and light radii (default: 1)
+ Aura and light radii use the average of the X and Y
+ scale factors.
+
+ --cycles N Number of times to run through animation frames.
+ Overrides the animation's cycles setting.
+
+ -p ID, --page ID ID of the page on which to display the animation
+ -P, --playerpage Display animation on page with player ribbon.
+ If no page is specified, the page containing the
+ currently selected token will be used. If no token is
+ selected, the page with the player ribbon will be used.
+
+
+Examples:
+
+!anim add image HighDensityFire
+ With a token selected, will name the token's image "HighDensityFire" for
+ later reference.
+
+!anim add animation Fireball
+ Will create an empty animation named "Fireball"
+
+!anim add frame Fireball HighDensityFire 50 -w 35 -h 35 --light 10 -dim 5
+ Will create a 35x35 (half-square) frame using the "HighDensityFire" image.
+ The frame will last for 50ms (1/20th of a second), and will emit 5 feet
+ (1 square) of bright light and 5 further feet of dim light.
+
+!anim add frame Fireball --copy 0 --rotation 10 -w 70 -h 70 -l 20 -d 10
+ Will create a 70x70 (full-square) frame using the first frame as a template.
+ The new frame will be rotated 10 degrees clockwise, and will have both its
+ size and light radius doubled.
+
+!anim add frame Fireball -C -1 -I MidDensityFire -r 20 -w 140 -h 140 -l 40 -d 20
+ Will create another, larger frame, using the second frame as a template.
+ This one will use a different image, will be rotated a further 10 degrees
+ clockwise, and will have both its size and light radius doubled again.
+
+!anim edit frame Fireball 2 -I HighDensityFire
+ If we decide the third frame was too early to switch to the "MidDensityFire"
+ image, this command will change its image to "HighDensityFire".
+
+!anim edit HighDensityFire http://...
+ Will change the "HighDensityFire" image to the new image. Note that this
+ will not affect existing frames using the "HighDensityFire" image; they will
+ continue to use whatever that name pointed at when they were defined.
+
+!anim remove frame Fireball 17
+ Remove the 18th frame of the "Fireball" animation.
+
+!anim list image
+ Will whisper a list of currently defined images, in alphabetical order, to
+ the user who executed the command.
+
+!anim list image HighDensityFire
+ Will whisper the URL of "HighDensityFire" to the user who executed the
+ command, displaying the image in the whisper.
+
+!anim list animation
+ Will whisper a list of currently defined animations, in alphabetical order,
+ to the user who executed the command.
+
+!anim list animation Spinner
+ Will whisper information about the specified animation to the user who
+ executed the command.
+
+!anim list frame Fireball
+ Will whisper a list of frames in the "Fireball" animation to the user who
+ executed the command.
+
+!anim list frame Fireball 0
+ Will whisper all the properties of the first frame of "Fireball" to the
+ user who executed the command
+
+!anim run Fireball
+ With a token selected, will run the "Fireball" animation centered on the
+ selected token.
+
+!anim run Fireball -f 5 -x 500 -y 350 --xscale 2 --yscale 2
+ Will run the "Fireball" animation centered at (500,350), doubled in size
+ and running in slow-motion (taking five times as long to run).
diff --git a/Animation/animation.js b/Animation/animation.js
new file mode 100644
index 0000000000..b66f4cdf12
--- /dev/null
+++ b/Animation/animation.js
@@ -0,0 +1,823 @@
+var Animation = Animation || {
+ MIN_FRAME_DURATION: 30,
+
+ ARG_MAP: {'-x': "x", '-y': "y",
+ '-w': "width", '--width': "width",
+ '-h': "height", '--height': "height",
+ '-r': "rotation", '--rotation': "rotation",
+ '-a': "auraRadius", '--aura': "auraRadius",
+ '--auracolor': "auraColor",
+ '-t': "tint", '--tintcolor': "tint",
+ '-l': "lightRadius",'--light': "lightRadius",
+ '-d': "lightDim", '--dim': "lightDim",
+ '--lightangle': "lightAngle",
+ '-I': "image", '--image': "image",
+ '-D': "duration", '--duration': "duration",
+ '-i': "insertIdx", '--insert': "insertIdx",
+ '-C': "copyIdx", '--copy': "copyIdx",
+ '-f': "timeScale", '--timefactor': "timeScale",
+ '--xscale': "xScale",
+ '--yscale': "yScale",
+ '--cycles': "cycles",
+ '-p': "pageId", '--page': "pageId"},
+
+ FRAME_DEFAULTS: {'x': 0, 'y': 0, 'width': 70, 'height': 70, 'rotation': 0,
+ 'auraRadius': "", 'auraColor': "#ffff99", 'auraSquare': false,
+ 'tint': "transparent", 'lightRadius': "", 'lightDim': "", 'lightAngle': 360},
+
+ jobTimers: {},
+
+ init: function(){
+ if (!state.hasOwnProperty('Animation')){ state.Animation = {}; }
+ if (!state.Animation.hasOwnProperty('images')){ state.Animation.images = {}; }
+ if (!state.Animation.hasOwnProperty('animations')){ state.Animation.animations = {}; }
+ if (!state.Animation.hasOwnProperty('running')){ state.Animation.running = {}; }
+
+ for (var jobId in state.Animation.running){
+ Animation.updateJob(jobId);
+ }
+ },
+
+ removeRunningAnimation: function(jobId){
+ if (!state.Animation.running[jobId]){ return; }
+ if (state.Animation.running[jobId].deleting){ return; }
+ state.Animation.running[jobId].deleting = true;
+ if (Animation.jobTimers[jobId]){
+ clearTimeout(Animation.jobTimers[jobId]);
+ delete Animation.jobTimers[jobId];
+ }
+ var tok = getObj("graphic", jobId);
+ if (tok){ tok.remove(); }
+ delete state.Animation.running[jobId];
+ },
+
+ handleDelete: function(tok){
+ if (!state.Animation.running[tok.id]){ return; }
+ if (state.Animation.running[tok.id].deleting){ return; }
+ if (!Animation.jobTimers[tok.id]){
+ // not deleting job, but no timer; job must be initializing or changing frames
+ state.Animation.running[tok.id].cancel = true;
+ return;
+ }
+ Animation.removeRunningAnimation(tok.id);
+ },
+
+ updateJob: function(jobId){
+ if (Animation.jobTimers[jobId]){
+ delete Animation.jobTimers[jobId];
+ }
+ var job = state.Animation.running[jobId];
+ if ((!job) || (job.deleting)){ return; }
+ if (job.cancel){
+ // job was canceled; remove it
+ Animation.removeRunningAnimation(jobId);
+ return;
+ }
+ var tok = getObj("graphic", jobId);
+ if (!tok){
+ // token no longer exists; remove job
+ Animation.removeRunningAnimation(jobId);
+ return;
+ }
+
+ // update to next frame
+ job.curFrame = (job.curFrame + 1) % job.frames.length;
+ if (job.curFrame == 0){
+ // job finished a cycle; move on to the next one
+ job.cycles = job.cycles - 1;
+ }
+ if (job.cycles <= 0){
+ // job has run out of cycles and is finished; remove it
+ Animation.removeRunningAnimation(jobId);
+ return;
+ }
+
+ var handlerFunc = function(){ Animation.updateJob(jobId); }
+ tok.set(job.frames[job.curFrame].properties);
+ Animation.jobTimers[jobId] = setTimeout(handlerFunc, job.frames[job.curFrame].duration);
+ },
+
+ addJob: function(animName, pageId, x, y, rotation, timeScale, xScale, yScale, cycles){
+ var anim = state.Animation.animations[animName];
+ if (!anim){ return "Error: No animation defined with name " + animName; }
+ if (anim.frames.length <= 0){ return "Error: Animation '" + animName + "' does not have any frames"; }
+ if ((cycles || anim.cycles) == 0){ return "Error: Cannot run animation '" + animName + "' without specifying nonzero cycle count"; }
+
+ var job = {'frames': [], 'curFrame': 0, 'cycles': cycles || anim.cycles};
+ for (var i = 0; i < anim.frames.length; i++){
+ if (anim.frames[i].duration * timeScale < Animation.MIN_FRAME_DURATION){
+ return "Error: Time factor reduces duration below MIN_FRAME_DURATION (" + Animation.MIN_FRAME_DURATION + ")";
+ }
+ var props = {'imgsrc': anim.frames[i].url,
+ 'left': x + (anim.frames[i].x * xScale),
+ 'top': y + (anim.frames[i].y * yScale),
+ 'width': anim.frames[i].width * xScale,
+ 'height': anim.frames[i].height * yScale,
+ 'rotation': anim.frames[i].rotation + rotation,
+ 'aura1_radius': anim.frames[i].auraRadius * (xScale + yScale) / 2,
+ 'aura1_color': anim.frames[i].auraColor,
+ 'aura1_square': anim.frames[i].auraSquare,
+ 'tint_color': anim.frames[i].tint,
+ 'light_radius': anim.frames[i].lightRadius * (xScale + yScale) / 2,
+ 'light_dimradius': anim.frames[i].lightDim * (xScale + yScale) / 2,
+ 'light_angle': anim.frames[i].lightAngle};
+ job.frames.push({'properties': props, 'duration': anim.frames[i].duration * timeScale});
+ }
+
+ var tok = createObj("graphic", {'_subtype': "token",
+ '_pageid': pageId,
+ 'imgsrc': job.frames[0].properties.imgsrc,
+ 'left': job.frames[0].properties.left,
+ 'top': job.frames[0].properties.top,
+ 'width': job.frames[0].properties.width,
+ 'height': job.frames[0].properties.height,
+ 'rotation': job.frames[0].properties.rotation,
+ 'layer': "objects",
+ 'isdrawing': true,
+ 'aura1_radius': job.frames[0].properties.aura1_radius,
+ 'aura1_color': job.frames[0].properties.aura1_color,
+ 'aura1_square': job.frames[0].properties.aura1_square,
+ 'tint_color': job.frames[0].properties.tint_color,
+ 'light_radius': job.frames[0].properties.light_radius,
+ 'light_dimradius': job.frames[0].properties.light_dimradius,
+ 'light_otherplayers': true,
+ 'light_angle': job.frames[0].properties.light_angle});
+ state.Animation.running[tok.id] = job;
+ toFront(tok);
+
+ var handlerFunc = function(){ Animation.updateJob(tok.id); }
+ if (!state.Animation.running[tok.id].cancel){
+ Animation.jobTimers[tok.id] = setTimeout(handlerFunc, job.frames[0].duration);
+ }
+ },
+
+ write: function(s, who, style, from){
+ if (who){
+ who = "/w " + who.split(" ", 1)[0] + " ";
+ }
+ sendChat(from, who + s.replace(//g, ">").replace(/\n/g, "
"));
+ },
+
+ showHelp: function(who, cmd, subCmd){
+ var usage = "", helpMsg = "";
+ switch (subCmd){
+ case "add":
+ usage += "Usage: " + cmd + " add image NAME [URL]\n";
+ usage += " or: " + cmd + " add animation NAME [CYCLES]\n";
+ usage += " or: " + cmd + " add frame ANIMATION IMAGE DURATION [options]\n";
+ usage += "In the first form, an image will be named for later reference.\n";
+ usage += "In the second form, an empty animation will be created.\n";
+ usage += "In the third form, a frame will be added to the specified animation.";
+ helpMsg += "Parameters:\n";
+ helpMsg += " NAME: Name under which the new item will be added\n";
+ helpMsg += " URL: Image URL (default is selected token's image)\n";
+ helpMsg += " CYCLES: Number of times to run through animation frames (default: 1)\n";
+ helpMsg += " ANIMATION: Name of animation to which to add frame\n";
+ helpMsg += " IMAGE: Name or URL of image for new frame\n";
+ helpMsg += " DURATION: Number of milliseconds to show the frame\n";
+ helpMsg += "Options:\n";
+ helpMsg += " -x X, -y Y Frame offset, in pixels, from animation origin (default: 0)\n";
+ helpMsg += " -w W, --width W Frame width in pixels (default: 70)\n";
+ helpMsg += " -h H, --height H Frame height in pixels (default: 70)\n";
+ helpMsg += " -r A, --rotation A Frame rotation in degrees (default: 0)\n";
+ helpMsg += " -a R, --aura R Radius, in page units, of frame's aura (default: none)\n";
+ helpMsg += " --auracolor C Color of frame's aura (default: #FFFF99)\n";
+ helpMsg += " --aurasquare Frame's aura will be a square (default: circle)\n";
+ helpMsg += " -t C, --tint C Color tint to be applied to frame (default: none)\n";
+ helpMsg += " -l R, --light R Radius, in page units, of frame's light (default: none)\n";
+ helpMsg += " -d R, --dim R Radius at which light begins to dim (default: none)\n";
+ helpMsg += " --lightangle A Angle, in degrees, of emitted light (default: 360)\n";
+ helpMsg += " -i I, --insert I Index (0-based) at which to insert new frame (default: end)\n";
+ helpMsg += " -C I, --copy I Index of frame to copy (IMAGE and DURATION are optional).\n";
+ helpMsg += " If I is negative, it is relative to insertion index\n";
+ helpMsg += " -I IMG, --image IMG As IMAGE parameter, for use with --copy\n";
+ helpMsg += " -D T, --duration T As DURATION parameter, for use with --copy\n";
+ break;
+ case "edit":
+ usage += "Usage: " + cmd + " edit image NAME [URL]\n";
+ usage += " or: " + cmd + " edit animation NAME CYCLES\n";
+ usage += " or: " + cmd + " edit frame ANIMATION FRAME_INDEX [options]\n";
+ usage += "In the first form, a named image will be replaced with a new image.\n";
+ usage += "In the second form, a named animation will be modified.\n";
+ usage += "In the third form, the specified frame will be modified.";
+ helpMsg += "Parameters:\n";
+ helpMsg += " NAME: Name of item to modify\n";
+ helpMsg += " URL: Image URL (default is selected token's image)\n";
+ helpMsg += " CYCLES: Number of times to run through animation frames (default: 1)\n";
+ helpMsg += " ANIMATION: Name of animation in which to modify frame\n";
+ helpMsg += " FRAME_INDEX: Index (0-based) of frame to modify\n";
+ helpMsg += "Options:\n";
+ helpMsg += " -x X, -y Y Frame offset, in pixels, from animation origin\n";
+ helpMsg += " -w W, --width W Frame width in pixels\n";
+ helpMsg += " -h H, --height H Frame height in pixels\n";
+ helpMsg += " -r A, --rotation A Frame rotation in degrees, relative to animation\n";
+ helpMsg += " -a R, --aura R Radius, in page units, of frame's aura\n";
+ helpMsg += " --auracolor C Color of frame's aura\n";
+ helpMsg += " --aurasquare Frame's aura will be a square\n";
+ helpMsg += " -t C, --tint C Color tint to be applied to frame\n";
+ helpMsg += " -l R, --light R Radius, in page units, of frame's light\n";
+ helpMsg += " -d R, --dim R Radius at which light begins to dim\n";
+ helpMsg += " --lightangle A Angle, in degrees, of emitted light\n";
+ helpMsg += " -I IMG, --image IMG Name or URL of frame image\n";
+ helpMsg += " -D T, --duration T Number of milliseconds to show the frame\n";
+ break;
+ case "remove":
+ usage += "Usage: " + cmd + " remove image NAME\n";
+ usage += " or: " + cmd + " remove animation NAME\n";
+ usage += " or: " + cmd + " remove frame ANIMATION FRAME_INDEX\n";
+ usage += "In the first form, a named image will be removed.\n";
+ usage += "In the second form, a named animation will be removed.\n";
+ usage += "In the third form, the specified frame will be removed.";
+ helpMsg += "Parameters:\n";
+ helpMsg += " NAME: Name of item to remove\n";
+ helpMsg += " ANIMATION: Name of animation from which to remove frame\n";
+ helpMsg += " FRAME_INDEX: Index (0-based) of frame to remove\n";
+ break;
+ case "list":
+ usage += "Usage: " + cmd + " list image [NAME]\n";
+ usage += " or: " + cmd + " list animation [NAME]\n";
+ usage += " or: " + cmd + " list frame ANIMATION [FRAME_INDEX]\n";
+ usage += "The first form will list the names of all images or the URL of a specified image.\n";
+ usage += "The second form will list the names of all animations or summary info about a specified animation.\n";
+ usage += "The third form will list the frames of an animation or all properties of a specified frame.";
+ helpMsg += "Parameters:\n";
+ helpMsg += " NAME: Name of image/animation to describe\n";
+ helpMsg += " ANIMATION: Name of animation whose frames will be described\n";
+ helpMsg += " FRAME_INDEX: Index (0-based) of frame to describe\n";
+ break;
+ case "run":
+ usage += "Usage: " + cmd + " run NAME [options]\n";
+ helpMsg += "Parameters:\n";
+ helpMsg += " NAME: Name of animation to run\n";
+ helpMsg += "Options:\n";
+ helpMsg += " -x X, -y Y Coordinates, in pixels, at which to display animation\n";
+ helpMsg += " (default: selected token's position, or page center)\n";
+ helpMsg += " -r A, --rotation A Animation rotation in degrees (default: 0)\n";
+ helpMsg += " -f F, --timefactor F Scale factor for frame durations (default: 1)\n";
+ helpMsg += " --xscale F Size scale factor in the X direction (default: 1)\n";
+ helpMsg += " --yscale F Size scale factor in the Y direction (default: 1)\n";
+ helpMsg += " (light and auras are scaled by the average of X and Y scale)\n";
+ helpMsg += " --cycles N Number of times to run through animation frames\n";
+ helpMsg += " (default: retain cycles defined in animation)\n";
+ helpMsg += " -p ID, --page ID ID of page on which to display animation\n";
+ helpMsg += " -P, --playerpage Display animation on page with player ribbon\n";
+ helpMsg += " If no page is specified, page with selected token will be used.\n";
+ helpMsg += " If no token is selected, page with player ribbon will be used\n";
+ break;
+ default:
+ usage += "Usage: " + cmd + " COMMAND [options]";
+ helpMsg += "help [COMMAND]: display generic or command-specific help\n";
+ helpMsg += "add TYPE [...]: add/name an image, animation, or frame\n";
+ helpMsg += "edit TYPE [...]: edit a previously-added item\n";
+ helpMsg += "remove TYPE NAME: remove a previously-added item\n";
+ helpMsg += "list TYPE [...]: display information about items\n";
+ helpMsg += "run NAME [...]: display a specified animation\n";
+ }
+ Animation.write(usage, who, "", "Anim");
+ Animation.write(helpMsg, who, "font-size: small; font-family: monospace", "Anim");
+ },
+
+ fixupCommand: function(cmd, inlineRolls){
+ function replaceInlines(s){
+ if (!inlineRolls){ return s; }
+ var i = parseInt(s.substring(3, s.length - 2));
+ if ((i < 0) || (i >= inlineRolls.length) || (!inlineRolls[i]) || (!inlineRolls[i]['expression'])){ return s; }
+ return "[[" + inlineRolls[i]['expression'] + "]]";
+ }
+ return cmd.replace(/\$\[\[\d+\]\]/g, replaceInlines);
+ },
+
+ addImage: function(imgName, url){
+ if (state.Animation.images[imgName]){
+ return "Error: Image '" + imgName + "' already defined; please use edit or remove command";
+ }
+ state.Animation.images[imgName] = url;
+ },
+
+ editImage: function(imgName, url){
+ if (!state.Animation.images[imgName]){
+ return "Error: Image '" + imgName + "' not defined; please use add command";
+ }
+ state.Animation.images[imgName] = url;
+ },
+
+ removeImage: function(imgName){
+ if (!state.Animation.images[imgName]){
+ return "Warning: Image '" + imgName + "' not defined";
+ }
+ delete state.Animation.images[imgName];
+ },
+
+ listImage: function(who, imgName){
+ var output = "";
+ if (!imgName){
+ var imgNames = [];
+ for (var k in state.Animation.images){
+ if (state.Animation.images.hasOwnProperty(k)){ imgNames.push(k); }
+ }
+ if (imgNames.length <= 0){
+ Animation.write("No images defined", who, "", "Anim");
+ return;
+ }
+ imgNames.sort();
+ output = imgNames.join("\n");
+ }
+ else if (!state.Animation.images[imgName]){
+ return "Error: Image '" + imgName + "' not defined";
+ }
+ else{
+ output = imgName + ": " + state.Animation.images[imgName] + "\n[" + imgName + "](";
+ output += state.Animation.images[imgName].replace(/([/][^/]+[.])(jpg|png).*$/, "$1$2") + ")";
+ }
+ Animation.write(output, who, "font-size: small; font-family: monospace", "Anim");
+ },
+
+ addAnimation: function(animName, cycles){
+ if (state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' already defined; please use edit or remove command";
+ }
+ state.Animation.animations[animName] = {'frames': [], 'cycles': cycles};
+ },
+
+ editAnimation: function(animName, cycles){
+ if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined; please use add command";
+ }
+ state.Animation.animations[animName].cycles = cycles;
+ },
+
+ removeAnimation: function(animName){
+ if (!state.Animation.animations[animName]){
+ return "Warning: Animation '" + animName + "' not defined";
+ }
+ delete state.Animation.animations[animName];
+ },
+
+ listAnimation: function(who, animName){
+ var output = "";
+ if (!animName){
+ var animNames = [], animStats = {};
+ for (var k in state.Animation.animations){
+ if (!state.Animation.animations.hasOwnProperty(k)){ continue; }
+ animNames.push(k);
+ var anim = state.Animation.animations[k];
+ animStats[k] = {'duration': 0, 'frames': anim.frames.length * anim.cycles};
+ for (var i = 0; i < anim.frames.length; i++){
+ animStats[k].duration += anim.frames[i].duration;
+ }
+ animStats[k].duration *= anim.cycles;
+ }
+ if (animNames.length <= 0){
+ Animation.write("No animations defined", who, "", "Anim");
+ return;
+ }
+ animNames.sort();
+ for (var i = 0; i < animNames.length; i++){
+ if (i > 0){ output += "\n"; }
+ output += animNames[i] + ": ";
+ output += (animStats[animNames[i]].duration / 1000) + "s (";
+ output += animStats[animNames[i]].frames + " frames)";
+ }
+ }
+ else if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined";
+ }
+ else{
+ var anim = state.Animation.animations[animName];
+ var duration = 0;
+ for (var i = 0; i < anim.frames.length; i++){
+ duration += anim.frames[i].duration;
+ }
+ output = animName;
+ if (anim.cycles > 1){
+ output += " (" + anim.cycles + " cycles)";
+ }
+ output += ":\n Frames: " + anim.frames.length;
+ if (anim.cycles > 1){
+ output += " (x" + anim.cycles + " = " + (anim.frames.length * anim.cycles) + ")";
+ }
+ output += "\n Duration: " + (duration / 1000) + "s";
+ if (anim.cycles > 1){
+ output += " (x" + anim.cycles + " = " + (duration * anim.cycles / 1000) + "s)";
+ }
+ }
+ Animation.write(output, who, "font-size: small; font-family: monospace", "Anim");
+ },
+
+ numify: function(x){
+ var xNum = x;
+ if (typeof(x) == typeof("")){
+ xNum = parseFloat(x);
+ }
+ if ("" + xNum == "" + x){ return xNum; }
+ return x;
+ },
+
+ addFrame: function(animName, idx, props, copyIdx){
+ if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined; please use add command";
+ }
+ var frames = state.Animation.animations[animName].frames;
+ var newFrame = {};
+ if ((typeof(idx) != typeof(0)) || (idx < 0) || (idx >= frames.length)){
+ idx = frames.length;
+ }
+ if (typeof(copyIdx) == typeof(0)){
+ if (copyIdx < 0){ copyIdx += idx; }
+ if ((copyIdx < 0) || (copyIdx >= frames.length)){
+ return "Error: Cannot copy from nonexistent frame " + copyIdx;
+ }
+ for (var k in frames[copyIdx]){
+ if (!frames[copyIdx].hasOwnProperty(k)){ continue; }
+ newFrame[k] = frames[copyIdx][k];
+ }
+ }
+
+ if (!newFrame.hasOwnProperty('url')){
+ if (!props['image']){
+ return "Error: Must specify frame image";
+ }
+ newFrame['url'] = state.Animation.images[props['image']] || props['image'];
+ }
+ if (!newFrame.hasOwnProperty('duration')){
+ if (!props['duration']){
+ return "Error: Must specify frame duration";
+ }
+ newFrame['duration'] = parseInt(props['duration']);
+ if (newFrame['duration'] < Animation.MIN_FRAME_DURATION){
+ return "Error: Duration must be greater than MIN_FRAME_DURATION (" + Animation.MIN_FRAME_DURATION + ")";
+ }
+ }
+
+ for (var k in Animation.FRAME_DEFAULTS){
+ if (!Animation.FRAME_DEFAULTS.hasOwnProperty(k)){ continue; }
+ newFrame[k] = (props.hasOwnProperty(k) ? Animation.numify(props[k]) : Animation.FRAME_DEFAULTS[k]);
+ }
+
+ frames.splice(idx, 0, newFrame);
+ },
+
+ editFrame: function(animName, idx, props){
+ if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined; please use add command";
+ }
+ var frames = state.Animation.animations[animName].frames;
+ if ((idx < 0) || (idx >= frames.length)){
+ return "Error: Animation '" + animName + "' frame " + idx + " does not exist; please use add command";
+ }
+ if (props['image']){
+ frames[idx]['url'] = state.Animation.images[props['image']] || props['image'];
+ }
+ if (props.hasOwnKey('duration')){
+ frames[idx]['duration'] = parseInt(props['duration']);
+ if (frames[idx]['duration'] < Animation.MIN_FRAME_DURATION){
+ return "Error: Duration must be greater than MIN_FRAME_DURATION (" + Animation.MIN_FRAME_DURATION + ")";
+ }
+ }
+ for (var k in Animation.FRAME_DEFAULTS){
+ if ((!Animation.FRAME_DEFAULTS.hasOwnProperty(k)) || (!props.hasOwnProperty(k))){ continue; }
+ frames[idx][k] = Animation.numify(props[k]);
+ }
+ },
+
+ removeFrame: function(animName, idx){
+ if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined";
+ }
+ var frames = state.Animation.animations[animName].frames;
+ if ((idx < 0) || (idx >= frames.length)){
+ return "Warning: Animation '" + animName + "' frame " + idx + " does not exist";
+ }
+ frames.splice(idx, 1);
+ },
+
+ listFrame: function(who, animName, idx){
+ if (!state.Animation.animations[animName]){
+ return "Error: Animation '" + animName + "' not defined; please use add command";
+ }
+ var frames = state.Animation.animations[animName].frames;
+ var output = "";
+ if (typeof(idx) != typeof(0)){
+ if (frames.length <= 0){
+ Animation.write("Animation '" + animName + "' has no frames", who, "", "Anim");
+ return;
+ }
+ for (var i = 0; i < frames.length; i++){
+ if (i > 0){ output += "\n"; }
+ output += i + ": ";
+ for (var k in state.Animation.images){
+ if (state.Animation.images[k] == frames[i].url){
+ output += k + ", ";
+ break;
+ }
+ }
+ output += frames[i].width + "x" + frames[i].height + " (" + (frames[i].duration / 1000) + "s)";
+ }
+ }
+ else if ((idx < 0) || (idx >= frames.length)){
+ return "Error: Animation '" + animName + "' frame " + idx + " does not exist; please use add command";
+ }
+ else{
+ output = animName + "." + idx + " (" + (frames[idx].duration / 1000) + "s)\n";
+ output += "[" + animName + "](" + frames[idx].url.replace(/([/][^/]+[.])(jpg|png).*$/, "$1$2") + ")";
+ var propNames = [];
+ for (var k in frames[idx]){
+ if (frames[idx].hasOwnProperty(k)){ propNames.push(k); }
+ }
+ propNames.sort();
+ for (var i = 0; i < propNames.length; i++){
+ output += "\n " + propNames[i] + ": " + frames[idx][propNames[i]];
+ }
+ }
+ Animation.write(output, who, "font-size: small; font-family: monospace", "Anim");
+ },
+
+ handleAnimationMessage: function(tokens, msg){
+ if (tokens.length <= 2){
+ return Animation.showHelp(msg.who, tokens[0], null);
+ }
+ var inlineRolls = msg.inlinerolls || [];
+ var args = {}, posArgs = [];
+ var getArg = null;
+ for (var i = 2; i < tokens.length; i++){
+ if (getArg){
+ if (getArg == "help"){
+ return Animation.showHelp(msg.who, tokens[0], tokens[i]);
+ }
+ args[getArg] = Animation.fixupCommand(tokens[i], inlineRolls);
+ getArg = null;
+ continue;
+ }
+ getArg = Animation.ARG_MAP[tokens[i]];
+ if (getArg){ continue; }
+ switch (tokens[i]){
+ case "--aurasquare":
+ args['auraSquare'] = true;
+ break;
+ case "-P":
+ case "--playerpage":
+ args['pageId'] = Campaign().get('playerpageid');
+ break;
+ default:
+ posArgs.push(Animation.fixupCommand(tokens[i], inlineRolls));
+ }
+ }
+ if (tokens[1] == "help"){
+ return Animation.showHelp(msg.who, tokens[0], tokens[2]);
+ }
+ if (getArg){
+ Animation.write("Error: Expected argument for " + getArg, msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], null);
+ }
+
+ var err;
+ switch (tokens[1]){
+ case "add":
+ switch (posArgs[0]){
+ case "image":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify image name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ var url = posArgs[2];
+ if ((!url) && (msg.selected)){
+ for (var i = 0; i < msg.selected.length; i++){
+ var tok = getObj(msg.selected[i]._type, msg.selected[i]._id);
+ if (tok){
+ url = tok.get('imgsrc');
+ if (url){ break; }
+ }
+ }
+ }
+ if (!url){
+ Animation.write("Error: Must specify image URL or select token", msg.who, "", "Anim");
+ return;
+ }
+ url = url.replace(/[/][^/]+[.](jpg|png)/, "/thumb.$1");
+ err = Animation.addImage(posArgs[1], url);
+ break;
+ case "animation":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ var cycles = parseInt(posArgs[2] || "1");
+ if ((!cycles) || (cycles < 0)){
+ Animation.write("Error: Invalid cycle count specification: " + posArgs[2], msg.who, "", "Anim");
+ return;
+ }
+ err = Animation.addAnimation(posArgs[1], cycles);
+ break;
+ case "frame":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ if (!args['copyIdx']){
+ if (!posArgs[2]){
+ Animation.write("Error: Must specify frame image", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ args['image'] = posArgs[2];
+ if (!posArgs[3]){
+ Animation.write("Error: Must specify frame duration", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ args['duration'] = posArgs[3];
+ }
+ var idx, copyIdx;
+ if (args['insertIdx']){ idx = parseInt(args['insertIdx']); }
+ if (args['copyIdx']){ copyIdx = parseInt(args['copyIdx']); }
+ err = Animation.addFrame(posArgs[1], idx, args, copyIdx);
+ break;
+ default:
+ Animation.write("Error: Unrecognized " + tokens[1] + " subcommand: " + posArgs[0], msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ break;
+ case "edit":
+ switch (posArgs[0]){
+ case "image":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify image name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ var url = posArgs[2];
+ if ((!url) && (msg.selected)){
+ for (var i = 0; i < msg.selected.length; i++){
+ var tok = getObj(msg.selected[i]._type, msg.selected[i]._id);
+ if (tok){
+ url = tok.get('imgsrc');
+ if (url){ break; }
+ }
+ }
+ }
+ if (!url){
+ Animation.write("Error: Must specify image URL or select token", msg.who, "", "Anim");
+ return;
+ }
+ url = url.replace(/[/][^/]+[.](jpg|png)/, "/thumb.$1");
+ err = Animation.editImage(posArgs[1], url);
+ break;
+ case "animation":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ if (!posArgs[2]){
+ Animation.write("Error: Must specify cycle count", msg.who, "", "Anim");
+ }
+ var cycles = parseInt(posArgs[2]);
+ if ((!cycles) || (cycles < 0)){
+ Animation.write("Error: Invalid cycle count specification: " + posArgs[2], msg.who, "", "Anim");
+ return;
+ }
+ err = Animation.addAnimation(posArgs[1], cycles);
+ break;
+ case "frame":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ if (!posArgs[2]){
+ Animation.write("Error: Must specify frame index", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ err = Animation.editFrame(posArgs[1], parseInt(posArgs[2]), args);
+ break;
+ default:
+ Animation.write("Error: Unrecognized " + tokens[1] + " subcommand: " + posArgs[0], msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ break;
+ case "remove":
+ switch (posArgs[0]){
+ case "image":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify image name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ err = Animation.removeImage(posArgs[1]);
+ break;
+ case "animation":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ err = Animation.removeAnimation(posArgs[1]);
+ break;
+ case "frame":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ if (!posArgs[2]){
+ Animation.write("Error: Must specify frame index", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ err = Animation.removeFrame(posArgs[1], parseInt(posArgs[2]));
+ break;
+ default:
+ Animation.write("Error: Unrecognized " + tokens[1] + " subcommand: " + posArgs[0], msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ break;
+ case "list":
+ switch (posArgs[0]){
+ case "image":
+ err = Animation.listImage(msg.who, posArgs[1]);
+ break;
+ case "animation":
+ err = Animation.listAnimation(msg.who, posArgs[1]);
+ break;
+ case "frame":
+ if (!posArgs[1]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ if (posArgs.length > 2){ posArgs[2] = parseInt(posArgs[2]); }
+ err = Animation.listFrame(msg.who, posArgs[1], posArgs[2]);
+ break;
+ default:
+ Animation.write("Error: Unrecognized " + tokens[1] + " subcommand: " + posArgs[0], msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ break;
+ case "run":
+ if (!posArgs[0]){
+ Animation.write("Error: Must specify animation name", msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], tokens[1]);
+ }
+ var pageId = args['pageId'];
+ if (!pageId){
+ if (msg.selected){
+ for (var i = 0; i < msg.selected.length; i++){
+ var tok = getObj(msg.selected[i]._type, msg.selected[i]._id);
+ if (tok){
+ pageId = tok._pageid;
+ break;
+ }
+ }
+ }
+ if (!pageId){ pageId = Campaign().get('playerpageid'); }
+ }
+ var page = getObj("page", pageId);
+ if (!page){
+ Animation.write("Error: Unable to get page " + pageId, msg.who, "", "Anim");
+ return;
+ }
+ var x = args['x'], y = args['y'];
+ if ((typeof(x) != typeof("")) || (typeof(y) != typeof(""))){
+ if (msg.selected){
+ for (var i = 0; i < msg.selected.length; i++){
+ var tok = getObj(msg.selected[i]._type, msg.selected[i]._id);
+ if (tok){
+ if (typeof(x) != typeof("")){ x = tok.get('left'); }
+ if (typeof(y) != typeof("")){ y = tok.get('top'); }
+ break;
+ }
+ }
+ }
+ }
+ if (typeof(x) == typeof("")){ x = parseInt(x); }
+ else if (typeof(x) != typeof(0)){ x = page.get('width') * 35; }
+ if (typeof(y) == typeof("")){ y = parseInt(y); }
+ else if (typeof(y) != typeof(0)){ y = page.get('height') * 35; }
+ var rotation = parseInt(args['rotation'] || "0");
+ var timeScale = parseFloat(args['timeScale'] || "1");
+ var xScale = parseFloat(args['xScale'] || "1");
+ var yScale = parseFloat(args['xScale'] || "1");
+ var cycles = parseInt(args['cycles'] || "0");
+ err = Animation.addJob(posArgs[0], pageId, x, y, rotation, timeScale, xScale, yScale, cycles);
+ break;
+ default:
+ Animation.write("Error: Unrecognized command: " + tokens[1], msg.who, "", "Anim");
+ return Animation.showHelp(msg.who, tokens[0], null);
+ }
+ if (typeof(err) == typeof("")){
+ Animation.write(err, msg.who, "", "Anim");
+ }
+ },
+
+ handleChatMessage: function(msg){
+ if ((msg.type != "api") || (msg.content.indexOf("!anim") != 0)){ return; }
+
+ return Animation.handleAnimationMessage(msg.content.split(" "), msg);
+ },
+
+ registerAnimation: function(){
+ Animation.init();
+ on("destroy:graphic", Animation.handleDelete);
+ if ((typeof(Shell) != "undefined") && (Shell) && (Shell.registerCommand)){
+ Shell.registerCommand("!anim", "!anim [args]", "Define or run animations", Animation.handleAnimationMessage);
+ if (Shell.write){
+ Animation.write = Shell.write;
+ }
+ }
+ else{
+ on("chat:message", Animation.handleChatMessage);
+ }
+ }
+};
+
+on("ready", function(){ Animation.registerAnimation(); });
diff --git a/Animation/package.json b/Animation/package.json
new file mode 100644
index 0000000000..173cbbfe3b
--- /dev/null
+++ b/Animation/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "Animation",
+ "version": "0.1",
+ "description": "Create and display animations.",
+ "authors": "manveti",
+ "roll20userid": "503018",
+ "dependencies": {},
+ "modifies": {},
+ "conflicts": []
+}