/* Epilogue UI elements */
$epilogueScreen = $('#epilogue-screen');
var epilogueContainer = document.getElementById('epilogue-container');

/* Epilogue selection modal elements */
$epilogueSelectionModal = $('#epilogue-modal'); //the modal box
$epilogueHeader = $('#epilogue-header-text'); //the header text for the epilogue selection box
$epilogueList = $('#epilogue-list'); //the list of epilogues
$epilogueAcceptButton = $('#epilogue-modal-accept-button'); //click this button to go with the chosen ending

var epilogueSelections = []; //references to the epilogue selection UI elements

var winStr = "You've won the game, and possibly made some friends. Who among these players did you become close with?"; //Winning the game, with endings available
var winStrNone = "You've won the game, and possibly made some friends? Unfortunately, none of your competitors are ready for a friend like you.<br>(None of the characters you played with have an ending written.)"; //Player won the game, but none of the characters have an ending written
var lossStr = "Well you lost. And you didn't manage to make any new friends. But you saw some people strip down and show off, and isn't that what life is all about?<br>(You may only view an ending when you win.)"; //Player lost the game. Currently no support for ending scenes when other players win

// Player won the game, but epilogues are disabled.
var winEpiloguesDisabledStr = "You won... but epilogues have been disabled.";

// Player lost the game with epilogues disabled.
var lossEpiloguesDisabledStr = "You lost... but epilogues have been disabled.";

var epilogues = []; //list of epilogue data objects
var chosenEpilogue = null;
var epiloguePlayer = null;
var epilogueSuffix = 0;

// Attach some event listeners
var previousButton = document.getElementById('epilogue-previous');
var nextButton = document.getElementById('epilogue-next');
previousButton.addEventListener('click', function (e) {
  e.preventDefault();
  e.stopPropagation();
  moveEpilogueBack();
});
nextButton.addEventListener('click', function (e) {
  e.preventDefault();
  e.stopPropagation();
  moveEpilogueForward();
});
document.getElementById('epilogue-restart').addEventListener('click', function (e) {
  e.preventDefault();
  e.stopPropagation();
  showRestartModal();
});
document.getElementById('epilogue-buttons').addEventListener('click', function () {
  if (!previousButton.disabled) {
    moveEpilogueBack();
  }
});
epilogueContainer.addEventListener('click', function () {
  if (!nextButton.disabled) {
    moveEpilogueForward();
  }
});

/************************************************************
 * Animation class. Used instead of CSS animations for the control over stopping/rewinding/etc.
 ************************************************************/
function Animation(id, frames, updateFunc, loop, easingFunction, clampFunction, iterations) {
  this.id = id;
  this.looped = loop === "1" || loop === "true";
  this.keyframes = frames;
  this.iterations = iterations;
  this.easingFunction = easingFunction || "smooth";
  this.clampFunction = clampFunction || "wrap";
  for (var i = 0; i < frames.length; i++) {
    frames[i].index = i;
    frames[i].keyframes = frames;
  }
  this.duration = frames[frames.length - 1].end;
  this.elapsed = 0;
  this.updateFunc = updateFunc;
}
Animation.prototype.easingFunctions = {
  "linear": function (t) { return t; },
  "smooth": function (t) { return 3 * t * t - 2 * t * t * t; },
  "ease-in": function (t) { return t * t; },
  "ease-out": function (t) { return t * (2 - t); },
  "elastic": function (t) { return (.04 - .04 / t) * Math.sin(25 * t) + 1; },
  "ease-in-cubic": function (t) { return t * t * t; },
  "ease-out-cubic": function (t) { t--; return 1 + t * t * t; },
  "ease-in-sin": function (t) { return 1 + Math.sin(Math.PI / 2 * t - Math.PI / 2); },
  "ease-out-sin": function (t) { return Math.sin(Math.PI / 2 * t); },
  "ease-in-out-cubic": function (t) { return t < .5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; },
  "ease-out-in": function (t) { return t < .5 ? Animation.prototype.easingFunctions["ease-out"](2 * t) * 0.5 : Animation.prototype.easingFunctions["ease-in"](2 * (t - 0.5)) * 0.5 + 0.5; },
  "ease-out-in-cubic": function (t) { return t < .5 ? Animation.prototype.easingFunctions["ease-out-cubic"](2 * t) * 0.5 : Animation.prototype.easingFunctions["ease-in-cubic"](2 * (t - 0.5)) * 0.5 + 0.5; },
  "bounce": function (t) {
    if (t < 0.3636) {
      return 7.5625 * t * t;
    }
    else if (t < 0.7273) {
      t -= 0.5455;
      return 7.5625 * t * t + 0.75;
    }
    else if (t < 0.9091) {
      t -= 0.8182;
      return 7.5625 * t * t + 0.9375;
    }
    else {
      t -= 0.9545;
      return 7.5625 * t * t + 0.984375;
    }
  },
};
Animation.prototype.isComplete = function () {
  var life = this.elapsed;
  if (this.looped) {
    return this.iterations > 0 ? life / this.duration >= this.iterations : false;
  }
  return life >= this.duration;
};
Animation.prototype.update = function (elapsedMs) {
  this.elapsed += elapsedMs;

  if (!this.updateFunc) { return; }

  //determine what keyframes we're between
  var last;
  var t = this.elapsed;
  if (t < 0) {
    return;
  }
  if (this.duration === 0) {
    t = 1;
  }
  else {
    var easingFunction = this.easingFunction;
    t /= this.duration;
    if (this.looped) {
      t = clampingFunctions[this.clampFunction](t);
      if (this.isComplete()) {
        t = 1;
      }
    }
    else {
      t = Math.min(1, t);
    }
    t = this.easingFunctions[easingFunction](t)
    t *= this.duration;
  }
  for (var i = this.keyframes.length - 1; i >= 0; i--) {
    var frame = this.keyframes[i];
    if (isNaN(frame.start)) { frame.start = 0; frame.end = 0; }
    if (t >= frame.start) {
      last = (i > 0 ? this.keyframes[i - 1] : frame);
      //normalize the time between frames
      var time;
      if (frame.end === frame.start) {
        time = 1;
      }
      else {
        time = t === 0 ? 0 : (t - frame.start) / (frame.end - frame.start);
      }
      this.updateFunc(this.id, last, frame, time);
      return;
    }
  }
}
Animation.prototype.halt = function () {
  var frame = this.keyframes[this.keyframes.length - 1];
  this.updateFunc && this.updateFunc(this.id, frame, frame, 1);
}

/************************************************************
 * Linear interpolation
 ************************************************************/
function lerp(a, b, t) {
  return (b - a) * t + a;
}

/************************************************************
 * Clamping functions for what to do with values that go outside [0:1] to put them back inside.
 ************************************************************/
var clampingFunctions = {
  "clamp": function (t) { return Math.max(0, Math.min(1, t)); },  //everything after 1 is clamped to 1
  "wrap": function (t) { return t % 1.0; },                       //passing 1 wraps back to 0 (ex. 1.1 => 0.1)
  "mirror": function (t) { t %= 2.0; return t > 1 ? 2 - t : t; }, //bouncing back and forth from 0->1->0 (ex. 1.1 => 0.9, 2.1 => 0.1)
};

/************************************************************
 * Interpolation functions for animation movement interpolation
 ************************************************************/
var interpolationModes = {
  "none": function noInterpolation(prop, start, end, t, frames, index) {
    return t >= 1 ? end : start;

  },
  "linear": function linear(prop, start, end, t, frames, index) {
    return lerp(start, end, t);
  },
  "spline": function catmullrom(prop, start, end, t, frames, index) {
    var p0 = index > 0 ? frames[index - 1][prop] : start;
    var p1 = start;
    var p2 = end;
    var p3 = index < frames.length - 1 ? frames[index + 1][prop] : end;

    if (typeof p0 === "undefined" || isNaN(p0)) {
      p0 = start;
    }
    if (typeof p3 === "undefined" || isNaN(p3)) {
      p3 = end;
    }

    var a = 2 * p1;
    var b = p2 - p0;
    var c = 2 * p0 - 5 * p1 + 4 * p2 - p3;
    var d = -p0 + 3 * p1 - 3 * p2 + p3;

    var p = 0.5 * (a + (b * t) + (c * t * t) + (d * t * t * t));
    return p;
  },
};

/************************************************************
 * Converts a px or % value to the equivalent scene value
 ************************************************************/
function toSceneX(x, scene) {
  if (typeof x === "undefined") { return; }
  if ($.isNumeric(x)) { return parseInt(x, 10); }
  if (x.endsWith("%")) {
    return parseInt(x, 10) / 100 * scene.width;
  }
  else {
    return parseInt(x, 10);
  }
}

/************************************************************
 * Converts a px or % value to the equivalent scene value
 ************************************************************/
function toSceneY(y, scene) {
  if (typeof y === "undefined") { return; }
  if ($.isNumeric(y)) { return parseInt(y, 10); }
  if (y.endsWith("%")) {
    return parseInt(y, 10) / 100 * scene.height;
  }
  else {
    return parseInt(y, 10);
  }
}

/************************************************************
 * Return the numerical part of a string s. E.g. "20%" -> 20
 ************************************************************/
function getNumericalPart(s) {
  return parseFloat(s); //apparently I don't actually need to remove the % (or anything else) from the string before I do the conversion
}

/************************************************************
 * Return the approriate left: setting so that a text box of the specified width is centred
 * Assumes a % width
 ************************************************************/
function getCenteredPosition(width) {
  var w = getNumericalPart(width); //numerical value of the width
  var left = 50 - (w / 2); //centre of the text box is at the 50% position
  return left + "%";
}

/************************************************************
 * Load the Epilogue data for a character
 ************************************************************/
 
// This is just a list of all possible conditional attribute names.
var EPILOGUE_CONDITIONAL_ATTRIBUTES = [
    'alsoPlaying', 'playerStartingLayers', 'markers',
    'not-markers', 'any-markers', 'alsoplaying-markers',
    'alsoplaying-not-markers', 'alsoplaying-any-markers'
]
 
function loadEpilogueData(player) {
  if (!player || !player.xml) { //return an empty list if a character doesn't have an XML variable. (Most likely because they're the player.)
    return [];
  }

  var playerGender = humanPlayer.gender;

  //get the XML tree that relates to the epilogue, for the specific player gender
  //var epXML = $($.parseXML(xml)).find('epilogue[gender="'+playerGender+'"]'); //use parseXML() so that <image> tags come through properly //IE doesn't like this

  var epilogues = player.xml.find('epilogue').filter(function (index) {
    /* Returning true from this function adds the current epilogue to the list of selectable epilogues.
     * Conversely, returning false from this function will make the current epilogue not selectable.
     */

    /* 'gender' attribute: the epilogue will only be selectable if the player character has the given gender, or if the epilogue is marked for 'any' gender. */
    var epilogue_gender = $(this).attr('gender');
    if (epilogue_gender && epilogue_gender !== playerGender && epilogue_gender !== 'any') {
      // if the gender doesn't match, don't make this epilogue selectable
      return false;
    }

    var alsoPlaying = $(this).attr('alsoPlaying');
    if (alsoPlaying !== undefined && !(players.some(function (p) { return p.id == alsoPlaying; }))) {
      return false;
    }

    var playerStartingLayers = parseInterval($(this).attr('playerStartingLayers'));
    if (playerStartingLayers !== undefined && !inInterval(humanPlayer.startingLayers, playerStartingLayers)) {
      return false;
    }

    /* 'markers' attribute: the epilogue will only be selectable if the character has ALL markers listed within the attribute set. */
    var all_marker_attr = $(this).attr('markers');
    if (all_marker_attr
      && !all_marker_attr.trim().split(/\s+/).every(function (marker) {
        return checkMarker(marker, player);
      })) {
      // not every marker set
      return false;
    }

    /* 'not-markers' attribute: the epilogue will only be selectable if the character has NO markers listed within the attribute set. */
    var no_marker_attr = $(this).attr('not-markers');
    if (no_marker_attr
      && no_marker_attr.trim().split(/\s+/).some(function (marker) {
        return checkMarker(marker, player);
      })) {
      // some disallowed marker set
      return false;
    }

    /* 'any-markers' attribute: the epilogue will only be selectable if the character has at least ONE of the markers listed within the attribute set. */
    var any_marker_attr = $(this).attr('any-markers');
    if (any_marker_attr
      && !any_marker_attr.trim().split(/\s+/).some(function (marker) {
        return checkMarker(marker, player);
      })) {
      // none of the markers set
      return false;
    }

    /* 'alsoplaying-markers' attribute: this epilogue will only be selectable if ALL markers within the attribute are set for any OTHER characters in the game. */
    var alsoplaying_marker_attr = $(this).attr('alsoplaying-markers');
    if (alsoplaying_marker_attr
      && !alsoplaying_marker_attr.trim().split(/\s+/).every(function (marker) {
        return players.some(function (p) {
          return p !== player && checkMarker(marker, p);
        });
      })) {
      // not every marker set by some other character
      return false;
    }

    /* 'alsoplaying-not-markers' attribute: this epilogue will only be selectable if NO markers within the attribute are set for other characters in the game. */
    var alsoplaying_not_marker_attr = $(this).attr('alsoplaying-not-markers');
    if (alsoplaying_not_marker_attr
      && alsoplaying_not_marker_attr.trim().split(/\s+/).some(function (marker) {
        return players.some(function (p) {
          return p !== player && checkMarker(marker, p);
        });
      })) {
      // some disallowed marker set by some other character
      return false;
    }

    /* 'alsoplaying-any-markers' attribute: this epilogue will only be selectable if at least one marker within the attribute are set for any OTHER character in the game. */
    var alsoplaying_any_marker_attr = $(this).attr('alsoplaying-any-markers');
    if (alsoplaying_any_marker_attr
      && !alsoplaying_any_marker_attr.trim().split(/\s+/).some(function (marker) {
        return players.some(function (p) {
          return p !== player && checkMarker(marker, p);
        });
      })) {
      // none of the markers set by any other player
      return false;
    }

    // if we made it this far the epilogue must be selectable
    return true;
  }).map(function (i, e) { return parseEpilogue(player, e); }).get();

  return epilogues;
}

var animatedProperties = ["x", "y", "rotation", "scalex", "scaley", "skewx", "skewy", "alpha", "src", "zoom", "color"];

function addDirectiveToScene(scene, directive) {
  switch (directive.type) {
    case "move":
    case "camera":
    case "fade":
      if (directive.keyframes.length > 1) {
        //split the directive into multiple directives so that all the keyframes affect the same properties
        //for instance, an animation with [frame1: {x:5, y:2}, frame2: {y:4}, frame3: {x:8, y:10}] would be split into two: [frame1: {x:5}, frame3: {x:8}] and [frame1: {y:2}, frame2: {y:4}, frame3: {y:10}]
        //this makes the tweening simple for properties that don't change in intermediate frames, because those intermediate frames are removed

        //first split the properties into buckets of frame indices where they appear
        var propertyMap = {};
        for (var i = 0; i < directive.keyframes.length; i++) {
          var frame = directive.keyframes[i];
          for (var j = 0; j < animatedProperties.length; j++) {
            var property = animatedProperties[j];
            if (frame.hasOwnProperty(property) && !Number.isNaN(frame[property])) {
              if (!propertyMap[property]) {
                propertyMap[property] = [];
              }
              propertyMap[property].push(i);
            }
          }
        }

        //next create directives for each combination of frames
        var directives = {};
        for (var prop in propertyMap) {
          var key = propertyMap[prop].join(',');
          var workingDirective = directives[key];
          if (!workingDirective) {
            //shallow copy the directive
            workingDirective = {};
            for (var srcProp in directive) {
              if (directive.hasOwnProperty(srcProp)) {
                workingDirective[srcProp] = directive[srcProp];
              }
            }
            workingDirective.keyframes = [];
            directives[key] = workingDirective;
            scene.directives.push(workingDirective);
          }
          var lastStart = 0;
          for (var i = 0; i < propertyMap[prop].length; i++) {
            var srcFrame = directive.keyframes[propertyMap[prop][i]];
            var targetFrame;
            if (workingDirective.keyframes.length <= i) {
              //shallow copy the frame minus the animatable properties
              targetFrame = {};
              for (var srcProp in srcFrame) {
                if (srcFrame.hasOwnProperty(srcProp)) {
                  targetFrame[srcProp] = srcFrame[srcProp];
                }
              }
              for (var j = 0; j < animatedProperties.length; j++) {
                var property = animatedProperties[j];
                delete targetFrame[property];
              }

              targetFrame.start = lastStart;
              workingDirective.keyframes.push(targetFrame);
              workingDirective.time = srcFrame.end;
              lastStart = srcFrame.end;
            }
            else {
              targetFrame = workingDirective.keyframes[i];
            }
            targetFrame[prop] = srcFrame[prop];
          }
        }
      }
      else {
        scene.directives.push(directive);
      }
      break;
    default:
      scene.directives.push(directive);
  }
}

function parseEpilogue(player, rawEpilogue, galleryEnding) {
  //use parseXML() so that <image> tags come through properly
  //not using parseXML() because internet explorer doesn't like it
  
  if (!rawEpilogue) {
    return;
  }

  player.markers = player.markers || {}; //ensure markers collection exists in the gallery even though they'll be empty

  var $epilogue = $(rawEpilogue);
  var title = $epilogue.find("title").html().trim();

  var epilogue = {
    title: title,
    player: player,
    scenes: [],
  };
  var scenes = epilogue.scenes;

  //determine what type of epilogue this is and parse accordingly
  var isLegacy = $epilogue.children("screen").length > 0;
  if (isLegacy) {
    parseLegacyEpilogue(player, epilogue, $epilogue);
  }
  else if ($epilogue.children("background").length > 0) {
    var sceneWidth, sceneHeight;
    var rawRatio = $epilogue.attr('ratio');
    if (rawRatio) {
      rawRatio = rawRatio.split(':');
      sceneWidth = parseFloat(rawRatio[0]);
      sceneHeight = parseFloat(rawRatio[1]);
    }
    parseNotQuiteLegacyEpilogue(player, epilogue, $epilogue, sceneWidth, sceneHeight);
  }
  else {
    var scene;
    $epilogue.children("scene").each(function (index, rawScene) {
      var $scene = $(rawScene);
      if ($scene.attr("transition")) {
        if (scenes.length === 0) {
          //add a blank scene to transition from
          scene = {
            color: $scene.attr("color"),
            directives: [],
          };
          scenes.push(scene);
        }
        scene = scenes[scenes.length - 1];
        scene.transition = readProperties(rawScene, scene);
      }
      else {
        var width = parseInt($scene.attr("width"), 10);
        var height = parseInt($scene.attr("height"), 10);
        scene = {
          background: $scene.attr("background"),
          width: width,
          height: height,
          aspectRatio: width / height,
          zoom: parseFloat($scene.attr("zoom"), 10),
          color: $scene.attr("color"),
          overlayColor: $scene.attr("overlay"),
          overlayAlpha: $scene.attr("overlay-alpha"),
          directives: [],
        }
        scenes.push(scene);
        scene.x = toSceneX($scene.attr("x"), scene);
        scene.y = toSceneY($scene.attr("y"), scene);

        var directives = scene.directives;

        $scene.find("directive").each(function (i, item) {
          var totalTime = 0;
          var directive = readProperties(item, scene);
          directive.keyframes = [];
          $(item).find("keyframe").each(function (i2, frame) {
            var keyframe = readProperties(frame, scene);
            keyframe.ease = keyframe.ease || directive.ease;
            keyframe.start = totalTime;
            totalTime = Math.max(totalTime, keyframe.time);
            keyframe.end = totalTime;
            keyframe.interpolation = directive.interpolation || "linear";
            directive.keyframes.push(keyframe);
          });
          if (directive.keyframes.length === 0) {
            //if no keyframes were explicitly provided, use the directive itself as a keyframe
            directive.start = 0;
            directive.end = directive.time;
            directive.keyframes.push(directive);
          }
          else {
            directive.time = totalTime;
          }

          if (directive.marker && !checkMarker(directive.marker, player)) {
            directive.type = "skip";
          }
          directives.push(directive);
        });
      }
    });

    //if the last scene has a transition, add a dummy scene to the end
    if (scenes.length > 0 && scenes[scenes.length - 1].transition) {
      scene = {
        color: scenes[scenes.length - 1].transition.color,
        directives: [],
      };
      scenes.push(scene);
    }
  }
  return epilogue;
}

/**
 * Parses an old screen-based epilogue and converts it into directive format
 */
function parseLegacyEpilogue(player, epilogue, $xml) {
  var scenes = epilogue.scenes;
  $xml.find("screen").each(function () {
    var $this = $(this);

    var image = $this.attr("img").trim();

    //create a scene for each screen
    var scene = {
      directives: [],
      background: image,
    };
    scenes.push(scene);
    parseSceneContent(player, scene, $this);
  });
}

/**
 * Parses an epilogue that came in the format background > scene > sprite/text and converts it into directive format
 */
function parseNotQuiteLegacyEpilogue(player, epilogue, $xml, sceneWidth, sceneHeight) {
  var scenes = epilogue.scenes;
  $xml.find('background').each(function () {
    var $this = $(this);
    var image = $this.attr('img').trim();
    if (image.length == 0) {
      image = '';
    }

    //create a directive-based scene for each scene in the background
    $this.find('scene').each(function () {
      var scene = {
        directives: [],
        background: image,
        width: sceneWidth,
        height: sceneHeight,
        aspectRatio: sceneWidth / sceneHeight,
      };
      scenes.push(scene);
      parseSceneContent(player, scene, $(this)); //this is intentionally $(this) instead of $this like in parseLegacyEpilogue
    });
  });
}

function parseSceneContent(player, scene, $scene) {
  var directive;
  var backgroundTransform = [$scene.attr('background-position-x'), $scene.attr('background-position-y'), $scene.attr('background-zoom') || 100];
  var addedPause = false;
  try {
    scene.x = toSceneX(backgroundTransform[0], scene);
    scene.y = toSceneY(backgroundTransform[1], scene);
    scene.zoom = parseFloat(backgroundTransform[2]) / 100;
  } catch (e) { }

  // Find the image data for this shot
  $scene.find('sprite').each(function () {
    var x = $(this).find("x").html().trim();
    var y = $(this).find("y").html().trim();
    var width = $(this).find("width").html().trim();
    var src = $(this).find('src').html().trim();

    var css = $(this).attr('css');

    directive = {
      type: "sprite",
      id: "obj" + (epilogueSuffix++),
      x: toSceneX(x, scene),
      y: toSceneY(y, scene),
      width: width,
      src: src,
      css: css,
    }
    scene.directives.push(directive);

  });

  //get the information for all the text boxes
  $scene.find("text").each(function () {

    //the text box's position and width
    var x = $(this).find("x").html().trim();
    var y = $(this).find("y").html().trim();
    var w = $(this).find("width").html();
    var a = $(this).find("arrow").html();

    //the width component is optional. Use a default of 20%.
    if (w) {
      w = w.trim();
    }
    if (!w || (w.length <= 0)) {
      w = "20%"; //default to text boxes having a width of 20%
    }

    //dialogue bubble arrow
    if (a) {
      a = a.trim().toLowerCase();
      if (a.length >= 1) {
        a = "arrow-" + a; //class name for the different arrows. Only use if the writer specified something.
      }
    } else {
      a = "";
    }

    //automatically centre the text box, if the writer wants that.
    if (x && x.toLowerCase() == "centered") {
      x = getCenteredPosition(w);
    }

    var text = fixupDialogue($(this).find("content").html().trim()); //the actual content of the text box

    var css = $(this).attr('css');

    directive = {
      type: "text",
      id: "obj" + (epilogueSuffix++),
      x: x,
      y: y,
      arrow: a,
      width: w,
      text: text,
      css: css,
    }
    scene.directives.push(directive);
    scene.directives.push({ type: "pause" });
    addedPause = true;
  });
  if (!addedPause) {
    scene.directives.push({ type: "pause" });
  }
}

/************************************************************
* Read attributes from a source XML object and put them into properties of a JS object
************************************************************/
function readProperties(sourceObj, scene) {
  var targetObj = {};
  var $obj = $(sourceObj);
  $.each(sourceObj.attributes, function (i, attr) {
    var name = attr.name.toLowerCase();
    var value = attr.value;
    targetObj[name] = value;
  });

  //properties needing special handling
  targetObj.delay = parseFloat(targetObj.delay, 10) * 1000 || 0;

  if (targetObj.type !== "text") {
    // scene directives
    targetObj.time = parseFloat(targetObj.time, 10) * 1000 || 0;
    if (targetObj.alpha) { targetObj.alpha = parseFloat(targetObj.alpha, 10); }
    targetObj.zoom = parseFloat(targetObj.zoom, 10);
    targetObj.rotation = parseFloat(targetObj.rotation, 10);
    targetObj.angle = parseFloat(targetObj.angle, 10) || 0;
    if (targetObj.scale) {
      targetObj.scalex = targetObj.scaley = targetObj.scale;
    }
    targetObj.scalex = parseFloat(targetObj.scalex, 10);
    targetObj.scaley = parseFloat(targetObj.scaley, 10);
    targetObj.skewx = parseFloat(targetObj.skewx, 10);
    targetObj.skewy = parseFloat(targetObj.skewy, 10);
    if (targetObj.x) { targetObj.x = toSceneX(targetObj.x, scene); }
    if (targetObj.y) { targetObj.y = toSceneY(targetObj.y, scene); }
    targetObj.iterations = parseInt(targetObj.iterations) || 0;
    targetObj.rate = parseFloat(targetObj.rate, 10) || 0;
    targetObj.count = parseFloat(targetObj.count, 10) || 0;
  }
  else {
    // textboxes

    // ensure an ID
    var id = targetObj.id;
    if (!id) {
      targetObj.id = "obj" + (epilogueSuffix++);
    }

    // text (not from an attribute, so not populated automatically)
    targetObj.text = fixupDialogue($obj.html().trim());

    var w = targetObj.width;
    //the width component is optional. Use a default of 20%.
    if (w) {
      w = w.trim();
    }
    if (!w || (w.length <= 0)) {
      w = "20%"; //default to text boxes having a width of 20%
    }
    targetObj.width = w;

    //dialogue bubble arrow
    var a = targetObj.arrow; if (a) {
      a = a.trim().toLowerCase();
      if (a.length >= 1) {
        a = "arrow-" + a; //class name for the different arrows. Only use if the writer specified something.
      }
    } else {
      a = "";
    }
    targetObj.arrow = a;

    //automatically centre the text box, if the writer wants that.
    var x = targetObj.x;
    if (x && x.toLowerCase() == "centered") {
      targetObj.x = getCenteredPosition(w);
    }
  }
  return targetObj;
}

/************************************************************
 * Add the epilogue to the Epilogue modal
 ************************************************************/

function addEpilogueEntry(epilogue) {
  var num = epilogues.length; //index number of the new epilogue
  epilogues.push(epilogue);
  var player = epilogue.player;

  var nameStr = player.first + " " + player.last;
  if (player.first.length <= 0 || player.last.length <= 0) {
    nameStr = player.first + player.last; //only use a space if they have both first and last names
  }

  var epilogueTitle = nameStr + ": " + epilogue.title;
  var idName = 'epilogue-option-' + num;
  var clickAction = "selectEpilogue(" + num + ")";
  var unlocked = save.hasEnding(player.id, epilogue.title) ? " unlocked" : "";

  var htmlStr = '<li id="' + idName + '" class="epilogue-entry' + unlocked + '"><button onclick="' + clickAction + '">' + epilogueTitle + '</button></li>';

  $epilogueList.append(htmlStr);
  epilogueSelections.push($('#' + idName));
}

/************************************************************
 * Clear the Epilogue modal
 ************************************************************/

function clearEpilogueList() {
  $epilogueHeader.html('');
  $epilogueList.html('');
  epilogues = [];
  epilogueSelections = [];
}

/************************************************************
 * Cleans up epilogue data
 ************************************************************/
function clearEpilogue() {
  if (epiloguePlayer) {
    epiloguePlayer.destroy();
    epiloguePlayer = null;
  }
}

/************************************************************
 * The user has clicked on a button to choose a particular Epilogue
 ************************************************************/

function selectEpilogue(epNumber) {
  chosenEpilogue = epilogues[epNumber]; //select the chosen epilogues

  for (var i = 0; i < epilogues.length; i++) {
    epilogueSelections[i].removeClass("active"); //make sure no other epilogue is selected
  }
  epilogueSelections[epNumber].addClass("active"); //mark the selected epilogue as selected
  $epilogueAcceptButton.prop("disabled", false); //allow the player to accept the epilogue
}

/************************************************************
 * Show the modal for the player to choose an Epilogue, or restart the game.
 ************************************************************/
function doEpilogueModal() {
  if (SENTRY_INITIALIZED) {
    Sentry.addBreadcrumb({
      category: 'ui',
      message: 'Showing epilogue modal.',
      level: 'info'
    });
  }

  clearEpilogueList(); //remove any already loaded epilogues
  chosenEpilogue = null; //reset any currently-chosen epilogue
  $epilogueAcceptButton.prop("disabled", true); //don't let the player accept an epilogue until they've chosen one

  //whether or not the human player won
  var playerWon = !humanPlayer.out;

  if (EPILOGUES_ENABLED && playerWon) { //all the epilogues are for when the player wins, so don't allow them to choose one if they lost
    //load the epilogue data for each player
    players.forEach(function (p) {
      loadEpilogueData(p).forEach(addEpilogueEntry);
    });
  }

  //are there any epilogues available for the player to see?
  var haveEpilogues = (epilogues.length >= 1); //whether or not there are any epilogues available
  $epilogueAcceptButton.css("visibility", haveEpilogues ? "visible" : "hidden");

  if (EPILOGUES_ENABLED) {
    //decide which header string to show the player. This describes the situation.
    var headerStr = '';
    if (playerWon) {
      headerStr = winStr; //player won, and there are epilogues available
      if (!haveEpilogues) {
        headerStr = winStrNone; //player won, but none of the NPCs have epilogues
      }
    } else {
      headerStr = lossStr; //player lost
    }
  } else {
    if (playerWon) {
      headerStr = winEpiloguesDisabledStr;
    } else {
      headerStr = lossEpiloguesDisabledStr;
    }
  }

  $epilogueHeader.html(headerStr); //set the header string
  $epilogueSelectionModal.modal("show");//show the epilogue selection modal
}

/************************************************************
 * Start the Epilogue
 ************************************************************/
function doEpilogue() {
  save.addEnding(chosenEpilogue.player.id, chosenEpilogue.title);

  if (USAGE_TRACKING) {
    var usage_tracking_report = {
      'date': (new Date()).toISOString(),
      'commit': VERSION_COMMIT,
      'type': 'epilogue',
      'session': sessionID,
      'game': gameID,
      'userAgent': navigator.userAgent,
      'origin': getReportedOrigin(),
      'table': {},
      'chosen': {
        'id': chosenEpilogue.player.id,
        'title': chosenEpilogue.title
      }
    };

    if (SENTRY_INITIALIZED) {
      Sentry.addBreadcrumb({
        category: 'epilogue',
        message: 'Starting '+chosenEpilogue.player.id+' epilogue: '+chosenEpilogue.title,
        level: 'info'
      });

      Sentry.setTag("epilogue_player", chosenEpilogue.player.id);
      Sentry.setTag("epilogue", chosenEpilogue.title);
      Sentry.setTag("epilogue_gallery", false);
      Sentry.setTag("screen", "epilogue");
    }

    for (let i = 1; i < 5; i++) {
      if (players[i]) {
        usage_tracking_report.table[i] = players[i].id;
      }
    }

    $.ajax({
      url: USAGE_TRACKING_ENDPOINT,
      method: 'POST',
      data: JSON.stringify(usage_tracking_report),
      contentType: 'application/json',
      error: function (jqXHR, status, err) {
        console.error("Could not send usage tracking report - error " + status + ": " + err);
      },
    });
  }

  epilogueContainer.dataset.background = -1;
  epilogueContainer.dataset.scene = -1;

  loadEpilogue(chosenEpilogue);

  screenTransition($gameScreen, $epilogueScreen);
  $epilogueSelectionModal.modal("hide");
}

/************************************************************
* Starts up an epilogue, pre-fetching all its images before displaying anything in order to handle certain computations that rely on the image sizes
************************************************************/
function loadEpilogue(epilogue) {
  $("#epilogue-spinner").show();
  epiloguePlayer = new EpiloguePlayer(epilogue);
  epiloguePlayer.load();
  updateEpilogueButtons();
}

function moveEpilogueForward() {
  if (epiloguePlayer && epiloguePlayer.loaded) {
    epiloguePlayer.advanceDirective();
    updateEpilogueButtons();
  }
}

function moveEpilogueBack() {
  if (epiloguePlayer && epiloguePlayer.loaded) {
    epiloguePlayer.revertDirective();
    updateEpilogueButtons();
  }
}

/************************************************************
 * Updates enabled state of buttons
 ************************************************************/

function updateEpilogueButtons() {
  if (!epiloguePlayer) {
    return;
  }

  var $epiloguePrevButton = $('#epilogue-buttons > #epilogue-previous');
  var $epilogueNextButton = $('#epilogue-buttons > #epilogue-next');
  var $epilogueRestartButton = $('#epilogue-buttons > #epilogue-restart');
  $epiloguePrevButton.prop("disabled", !epiloguePlayer.hasPreviousDirectives());
  $epilogueNextButton.prop("disabled", !epiloguePlayer.hasMoreDirectives());
  $epilogueRestartButton.prop("disabled", epiloguePlayer.hasMoreDirectives());
}

/************************************************************
* Class for playing through an epilogue
************************************************************/
function EpiloguePlayer(epilogue) {
  $(window).resize(this.resizeViewport.bind(this));
  this.epilogue = epilogue;
  this.lastUpdate = performance.now();
  this.sceneIndex = -1;
  this.directiveIndex = -1;
  this.assetMap = {};
  this.loaded = false;
  this.loadingImages = 0;
  this.totalImages = 0;
  this.loadedImages = 0;
  this.waitingForAnims = false;
  this.views = [];
  this.viewIndex = 0;
  this.activeTransition = null;
}

EpiloguePlayer.prototype.load = function () {
  for (var i = 0; i < this.epilogue.scenes.length; i++) {
    var scene = this.epilogue.scenes[i];
    if (scene.background) {
      scene.background = scene.background.charAt(0) === '/' ? scene.background.substring(1) : this.epilogue.player.base_folder + scene.background;
      this.fetchImage(scene.background);
    }
    for (var j = 0; j < scene.directives.length; j++) {
      var directive = scene.directives[j];
      if (directive.src) {
        directive.src = directive.src.charAt(0) === '/' ? directive.src.substring(1) : this.epilogue.player.base_folder + directive.src;
        this.fetchImage(directive.src);
      }
      
      if (directive.keyframes) {
          for (var k = 0; k < directive.keyframes.length; k++) {
            var keyframe = directive.keyframes[k];
            if (keyframe.src && keyframe !== directive) {
              keyframe.src = keyframe.src.charAt(0) === '/' ? keyframe.src.substring(1) : this.epilogue.player.base_folder + keyframe.src;
              this.fetchImage(keyframe.src);
            }
          }
      }
    }
  }
  this.readyToLoad = true;
  this.onLoadComplete();
}

/**
 * Called whenever all images being pre-fetched have been returned (which isn't necessarily when the total number of images that will be pre-fetched have been requested)
 * This is a workaround for IE11 not supporting promises
 */
EpiloguePlayer.prototype.onLoadComplete = function () {
  $("#epilogue-progress").text(Math.floor(this.loadedImages / Math.max(1, this.totalImages) * 100) + "%");
  if (this.loadingImages > 0) { return; }

  if (this.readyToLoad) {
    $("#epilogue-spinner").hide();
    var container = $("#epilogue-container");
    this.views.push(new SceneView(container, 0, this.assetMap));
    this.views.push(new SceneView(container, 1, this.assetMap));
    container.append($("<div id='scene-fade' class='epilogue-overlay' style='z-index: 10000'></div>")); //scene transition overlay
    this.loaded = true;
    this.advanceScene();
    window.requestAnimationFrame(this.loop.bind(this));
  }
}

/**
 * Fetches an image asset ahead of time so it's ready before we need it
 * @param {string} path URL for image
 */
EpiloguePlayer.prototype.fetchImage = function (path) {
  var img = new Image();
  this.loadingImages++;
  this.totalImages++;
  var $this = this;
  img.onload = img.onerror = function () {
    $this.assetMap[path] = img;
    $this.loadingImages--;
    $this.loadedImages++;
    $this.onLoadComplete();
  };
  img.src = path;
}

EpiloguePlayer.prototype.destroy = function () {
  for (var i = 0; i < this.views.length; i++) {
    this.views[i].destroy();
  }

  $("#scene-fade").remove();

  EpiloguePlayer.prototype.layer = 0;
}

EpiloguePlayer.prototype.hasMoreDirectives = function () {
  return this.sceneIndex < this.epilogue.scenes.length - 1 || this.directiveIndex < this.activeScene.directives.length - 1;
}

EpiloguePlayer.prototype.hasPreviousDirectives = function () {
  return this.sceneIndex > 0 || this.directiveIndex > 0;
}

EpiloguePlayer.prototype.loop = function (timestamp) {
  var elapsed = timestamp - this.lastUpdate;

  if (this.activeTransition) {
    this.activeTransition.update(elapsed);
    if (this.activeTransition.isComplete()) {
      this.activeTransition = null;
    }
  }

  for (var i = 0; i < this.views.length; i++) {
    if (this.views[i].isActive()) {
      this.update(elapsed);
      this.draw();
      break;
    }
  }

  this.lastUpdate = timestamp;
  window.requestAnimationFrame(this.loop.bind(this));
}

EpiloguePlayer.prototype.update = function (elapsed) {
  var nonLoopingCount = 0;
  for (var i = 0; i < this.views.length; i++) {
    nonLoopingCount += this.views[i].update(elapsed);
  }
  if (nonLoopingCount === 0 && this.waitingForAnims) {
    this.advanceDirective();
  }
}

EpiloguePlayer.prototype.draw = function () {
  for (var i = 0; i < this.views.length; i++) {
    this.views[i].draw();
  }
}

/** Advances to the next scene if there is one */
EpiloguePlayer.prototype.advanceScene = function () {
  this.sceneIndex++;
  if (this.sceneIndex < this.epilogue.scenes.length) {
    this.setupScene(this.sceneIndex);
  }
}

EpiloguePlayer.prototype.layer = 0;

EpiloguePlayer.prototype.setupScene = function (index, skipTransition) {
  var lastScene = this.activeScene;

  this.lastUpdate = performance.now();
  this.activeScene = this.epilogue.scenes[index];

  var view = this.activeScene.view = this.views[this.viewIndex];
  this.viewIndex = (this.viewIndex + 1) % this.views.length;
  this.directiveIndex = -1;

  view.setup(this.activeScene, index, this.epilogue, skipTransition ? null : lastScene);

  //fit the viewport based on the scene's aspect ratio and the window size
  this.resizeViewport();

  //scene transition effect
  if (lastScene) {
    if (lastScene.transition && this.activeScene && !skipTransition) {
      this.activeTransition = new SceneTransition(lastScene.view, this.activeScene.view, lastScene.transition, $("#scene-fade"));
    }
    if (!this.activeTransition) {
      lastScene.view.cleanup();
    }
  }

  this.performDirective();
}

EpiloguePlayer.prototype.resizeViewport = function () {
  if (!this.activeScene) {
    return;
  }

  for (var i = 0; i < this.views.length; i++) {
    this.views[i].resize();
  }

  this.draw();
}

EpiloguePlayer.prototype.advanceDirective = function () {
  if (this.activeTransition) { return; } //prevent advancing during a scene transition
  this.waitingForAnims = false;
  this.activeScene.view.haltAnimations(false);
  this.performDirective();
}

EpiloguePlayer.prototype.performDirective = function () {
  if (this.sceneIndex >= this.epilogue.scenes.length) { return; }
  this.directiveIndex++;
  if (this.directiveIndex < this.activeScene.directives.length) {
    var view = this.activeScene.view;
    var directive = this.activeScene.directives[this.directiveIndex];
    directive.action = null;
    if (directive.delay && directive.type !== "pause" && directive.type !== "wait") {
      view.pendDirective(this, directive, directive.delay);
    }
    else {
      if (view.runDirective(this, directive)) {
        return;
      }
    }
    this.performDirective();
  }
  else {
    this.advanceScene();
  }
}

/**
 * Reverts all changes up until the last "pause" directive
 */
EpiloguePlayer.prototype.revertDirective = function () {
  if (this.activeTransition) { return; }
  this.activeScene.view.haltAnimations(false);

  var canRevert = (this.sceneIndex > 0);
  if (!canRevert) {
    //on the initial scene, make sure there is a pause directive to revert to. Otherwise we can't rewind any further
    for (var i = this.directiveIndex - 1; i >= 0; i--) {
      if (this.activeScene.directives[i].type === "pause") {
        canRevert = true;
        break;
      }
    }
  }

  if (!canRevert) { return; }

  var currentIndex = this.directiveIndex;
  for (var i = currentIndex - 1; i >= 0; i--) {
    this.directiveIndex = i;
    var directive = this.activeScene.directives[i];
    if (directive.action) {
      directive.action.revert(directive, directive.action.context);
    }
    if (i < currentIndex - 1 && directive.type === "pause") {
      return;
    }
  }

  //reached the start of the scene, so time to back up an entire scene

  if (this.sceneIndex >= this.epilogue.scenes.length) {
    this.sceneIndex--; //the last scene had finished, so back up an extra time to move past that scene
  }

  //it would be better to make scene setup/teardown an undoable action, but for a quick and dirty method for now, just fast forward the whole scene to its last pause
  this.sceneIndex--;
  this.setupScene(this.sceneIndex, true);
  if (!this.activeTransition) {
    var pauseIndex;
    for (pauseIndex = this.activeScene.directives.length - 1; pauseIndex >= 0; pauseIndex--) {
      if (this.activeScene.directives[pauseIndex].type === "pause") {
        break;
      }
    }
    while (this.directiveIndex < pauseIndex) {
      this.advanceDirective();
    }
  }
}

fromHex = function (hex) {
  var value = parseInt(hex.substring(1), 16);
  var r = (value & 0xff0000) >> 16;
  var g = (value & 0x00ff00) >> 8;
  var b = (value & 0x0000ff);
  return [r, g, b];
}

toHexPiece = function (v) {
  var hex = Math.round(v).toString(16);
  if (hex.length < 2) {
    hex = "0" + hex;
  }
  return hex;
}

toHex = function (rgb) {
  return "#" + this.toHexPiece(rgb[0]) + this.toHexPiece(rgb[1]) + this.toHexPiece(rgb[2]);
}

EpiloguePlayer.prototype.awaitAnims = function (directive, context) {
  for (var i = 0; i < this.views.length; i++) {
    if (this.views[i].isAnimRunning()) {
      this.waitingForAnims = true;
      return;
    }
  }
  this.advanceDirective();
}

function SceneView(container, index, assetMap) {
  this.scene = null;
  this.index = index;
  this.pendingDirectives = [];
  this.anims = [];
  this.camera = null;
  this.assetMap = assetMap;
  this.sceneObjects = {};
  this.textObjects = {};
  this.viewportWidth = 0;
  this.viewportHeight = 0;
  this.particlePool = [];

  var viewport = this.$viewport = $("<div id='epilogue-viewport" + index + "' class='epilogue-viewport'></div>");
  this.$canvas = $("<div id='epilogue-canvas" + index + "' class='epilogue-canvas'></div>");
  viewport.append(this.$canvas);
  this.$overlay = $("<div id='epilogue-overlay" + index + "' class='epilogue-overlay'></div>");
  viewport.append(this.$overlay);
  this.$textContainer = $("<div id='epilogue-content" + index + "' class='epilogue-content'></div>");
  viewport.append(this.$textContainer);
  this.overlay = { rgb: [0, 0, 0], a: 0 };
  container.append(this.$viewport);
  viewport.hide();
}

SceneView.prototype.cleanup = function () {
  this.haltAnimations(true);

  //clear old textboxes
  this.$textContainer.empty();
  this.textObjects = {};

  //clear old images
  for (var obj in this.sceneObjects) {
    this.sceneObjects[obj].destroy();
  }
  this.sceneObjects = {};

  //hide until needed again
  this.$viewport.hide();
}

SceneView.prototype.destroy = function () {
  this.cleanup();

  this.particlePool = null;
  this.$viewport.remove();
}

SceneView.prototype.runDirective = function (epiloguePlayer, directive) {
  switch (directive.type) {
    case "sprite":
      this.addAction(directive, this.addSprite.bind(this), this.removeSceneObject.bind(this));
      break;
    case "text":
      this.addAction(directive, this.addText.bind(this), this.removeText.bind(this));
      break;
    case "clear":
      this.addAction(directive, this.clearText.bind(this), this.restoreText.bind(this));
      break;
    case "clear-all":
      this.addAction(directive, this.clearAllText.bind(this), this.restoreText.bind(this));
      break;
    case "move":
      this.addAction(directive, this.moveSprite.bind(this), this.returnSprite.bind(this));
      break;
    case "camera":
      this.addAction(directive, this.moveCamera.bind(this), this.returnCamera.bind(this));
      break;
    case "fade":
      this.addAction(directive, this.fade.bind(this), this.restoreOverlay.bind(this));
      break;
    case "stop":
      this.addAction(directive, this.stopAnimation.bind(this), this.restoreAnimation.bind(this));
      break;
    case "wait":
      this.addAction(directive, epiloguePlayer.awaitAnims.bind(epiloguePlayer), function () { });
      return true;
    case "pause":
      return true;
    case "remove":
      this.addAction(directive, this.hideSceneObject.bind(this), this.showSceneObject.bind(this));
      break;
    case "emitter":
      this.addAction(directive, this.addEmitter.bind(this), this.removeSceneObject.bind(this));
      break;
    case "emit":
      this.addAction(directive, this.burstParticles.bind(this), this.clearParticles.bind(this));
      break;
    case "skip":
      this.addAction(directive, function () { }, function () { });
      break;
  }
  return false;
}

/**
 * Adds an undoable action to the history
 * @param {any} context Context to pass to do and undo functions
 * @param {Function} doFunc Function to perform the directive
 * @param {Function} undoFunc Function to undo the directive
 */
SceneView.prototype.addAction = function (directive, doFunc, undoFunc) {
  var context = {}; //contextual information for the do action to store off that the revert action can refer to
  var action = { directive: directive, context: context, perform: doFunc, revert: undoFunc };
  directive.action = action;
  action.perform(directive, context);
}

SceneView.prototype.pendDirective = function (epiloguePlayer, directive, delay) {
  var info = { epiloguePlayer: epiloguePlayer, directive: directive };
  info.handle = setTimeout(this.runPendedDirective.bind(this), delay, info, true);
  this.pendingDirectives.push(info);
}

SceneView.prototype.runPendedDirective = function (info, remove) {
  info.handle = 0;
  this.runDirective(info.epiloguePlayer, info.directive);
  if (remove) {
    var index = this.pendingDirectives.indexOf(info);
    this.pendingDirectives.splice(index, 1);
  }
}

SceneView.prototype.updateOverlay = function (id, last, next, t) {
  if (typeof next.color !== "undefined") {
    var rgb1 = fromHex(last.color);
    var rgb2 = fromHex(next.color);

    var rgb = [0, 0, 0];
    for (var i = 0; i < rgb.length; i++) {
      rgb[i] = lerp(rgb1[i], rgb2[i], t);
    }
  }
  else {
    rgb = this.overlay.rgb;
  }
  var alpha = lerp(last.alpha, next.alpha, t);

  this.setOverlay(rgb, alpha);
}

SceneView.prototype.setOverlay = function (color, alpha) {
  if (typeof color !== "undefined") {
    this.overlay.rgb = color;
  }
  this.overlay.a = alpha;
  this.$overlay.css({
    "opacity": alpha / 100,
    "background-color": toHex(this.overlay.rgb)
  });
}

SceneView.prototype.isActive = function () {
  if (this.anims.length > 0) {
    return true;
  }
  for (var obj in this.sceneObjects) {
    var sceneObj = this.sceneObjects[obj];
    if (sceneObj instanceof Emitter && (sceneObj.activeParticles.length > 0 || sceneObj.rate > 0)) {
      return true;
    }
  }
  return false;
}

SceneView.prototype.update = function (elapsed) {
  var nonLoopingCount = this.pendingDirectives.length;

  for (var obj in this.sceneObjects) {
    this.sceneObjects[obj].update(elapsed);
  }

  for (var i = this.anims.length - 1; i >= 0; i--) {
    var anim = this.anims[i];
    anim.update(elapsed);
    if (anim.isComplete()) {
      this.anims.splice(i, 1);
    }
    else {
      if (!anim.looped) {
        nonLoopingCount++;
      }
    }
  }
  return nonLoopingCount;
}

SceneView.prototype.draw = function () {
  for (var obj in this.sceneObjects) {
    this.sceneObjects[obj].draw();
  }
}

SceneView.prototype.drawObject = function (obj) {
  if (!obj.element) { return; }
  var properties = [
    "scale(" + this.viewportWidth / this.scene.width * this.camera.zoom + ")",
    "translate(" + this.toViewX(obj.x) + ", " + this.toViewY(obj.y) + ")"
  ];
  var transform = properties.join(" ");

  $(obj.element).css({
    "transform": transform,
    "transform-origin": "top left",
    "opacity": obj.alpha / 100,
  });
  $(obj.rotElement).css({
    "transform": "rotate(" + obj.rotation + "deg) scale(" + obj.scalex + ", " + obj.scaley + ") skew(" + obj.skewx + "deg, " + obj.skewy + "deg)",
  });
}

SceneView.prototype.toViewX = function (x) {
  var sceneWidth = this.camera.width;
  var offset = sceneWidth / this.camera.zoom / 2 - sceneWidth / 2 + x - this.camera.x;
  return offset + "px";
}

SceneView.prototype.toViewY = function (y) {
  var sceneHeight = this.camera.height;
  var offset = sceneHeight / this.camera.zoom / 2 - sceneHeight / 2 + y - this.camera.y;
  return offset + "px";
}

SceneView.prototype.setup = function (scene, sceneIndex, epilogue, lastScene) {
  this.scene = scene;

  //copy the overlay values from the previous scene
  if (lastScene) {
    this.setOverlay(lastScene.view.overlay.rgb, lastScene.view.overlay.a);
  }
  else {
    //otherwise clear them completely
    this.setOverlay([0, 0, 0], 0);
  }

  if (!scene.width) {
    //if no scene dimensions were provided, use the background image's dimensions
    var backgroundImg = this.assetMap[scene.background];
    if (backgroundImg) {
      scene.width = backgroundImg.naturalWidth;
      scene.height = backgroundImg.naturalHeight;
      scene.aspectRatio = backgroundImg.naturalWidth / backgroundImg.naturalHeight;

      //backwards compatibility: for really skinny ratios, we probably don't want to use it since it'll make textboxes really squished. Use the first scene's instead
      if (sceneIndex > 0) {
        var previousScene = epilogue.scenes[0];
        if (scene.aspectRatio < 0.5) {
          scene.width = previousScene.width;
          scene.height = previousScene.height;
          scene.aspectRatio = previousScene.aspectRatio;
        }
      }
    }
  }

  this.camera = {
    x: isNaN(scene.x) ? 0 : toSceneX(scene.x, scene),
    y: isNaN(scene.y) ? 0 : toSceneY(scene.y, scene),
    width: scene.width,
    height: scene.height,
    zoom: scene.zoom || 1,
  }

  this.initOverlay(scene.overlayColor, scene.overlayAlpha);

  if (scene.background) {
    this.addBackground(scene.background);
  }
  this.$viewport.css({
    "background-color": scene.color,
    "z-index": EpiloguePlayer.prototype.layer++,
  });
  this.$viewport.show();
}

SceneView.prototype.initOverlay = function (rgb, a) {
  var alpha;
  if (!this.overlay.rgb) {
    this.setOverlay([0, 0, 0], 0);
  }
  if (a) {
    alpha = parseInt(a, 10);
    if (typeof alpha === "undefined") {
      alpha = 100;
    }
  }
  else {
    alpha = this.overlay.a || 0;
  }
  if (rgb) {
    this.setOverlay(fromHex(rgb), alpha);
  }
}

SceneView.prototype.resize = function () {
  if (!this.scene) {
    return;
  }
  var windowHeight = $(window).height();
  var windowWidth = $(window).width();

  var viewWidth = this.scene.aspectRatio * windowHeight;
  var width = viewWidth;
  var height = windowHeight;
  if (viewWidth > windowWidth) {
    //take full width of window
    width = windowWidth;
    height = windowWidth / this.scene.aspectRatio;
  }

  width = Math.ceil(width);
  height = Math.ceil(height);
  this.viewportWidth = width;
  this.viewportHeight = height;
  this.$viewport.width(width);
  this.$viewport.height(height);

  for (var id in this.textObjects) {
    var box = this.textObjects[id];
    var directive = box.data("directive");
    this.applyTextDirective(directive, box);
  }
}

SceneView.prototype.haltAnimations = function (haltLooping) {
  for (var i = 0; i < this.pendingDirectives.length; i++) {
    var info = this.pendingDirectives[i];
    if (info.handle) {
      clearTimeout(info.handle);
      this.runPendedDirective(info, false);
    }
  }
  this.pendingDirectives = [];

  var animloop = this.anims.slice();
  var j = 0;
  for (var i = 0; i < animloop.length; i++) {
    if (haltLooping || !animloop[i].looped) {
      animloop[i].halt();
      this.anims.splice(j, 1);
    }
    else {
      j++;
    }
  }
  this.draw();
}

SceneView.prototype.addBackground = function (background) {
  var img = this.assetMap[background];
  this.addImage("background", background, { x: 0, y: 0, width: img.naturalWidth + "px", height: img.naturalHeight + "px" });
}

SceneView.prototype.addImage = function (id, src, args) {
  var img = document.createElement("img");
  if (src) img.src = this.assetMap[src].src;

  var obj = new SceneObject(id, img, this, args);
  obj.setImage(src);
  this.addSceneObject(obj);
}

SceneView.prototype.addSprite = function (directive) {
  this.addImage(directive.id, directive.src, directive);
}

SceneView.prototype.addSceneObject = function (obj) {
  this.sceneObjects[obj.id] = obj;
  if (obj.element) {
    this.$canvas.append(obj.element);
  }
  this.draw();
}

SceneView.prototype.removeSceneObject = function (directive) {
  this.sceneObjects[directive.id].destroy();
  delete this.sceneObjects[directive.id];
}

SceneView.prototype.hideSceneObject = function (directive, context) {
  var sceneObject = context.object = this.sceneObjects[directive.id];
  context.anims = {};
  if (context.object) {
    $(context.object.element).hide();
    this.stopAnimation(directive, context.anims);
    context.rate = sceneObject.rate;
    sceneObject.rate = 0;
    if (!sceneObject instanceof Emitter) {
        delete this.sceneObjects[directive.id];
    }
  }
}

SceneView.prototype.showSceneObject = function (directive, context) {
  var obj = context.object;
  if (obj) {
    this.sceneObjects[directive.id] = obj;
    this.sceneObjects[directive.id].rate = context.rate;
    this.restoreAnimation(directive, context.anims);
    $(obj.element).show();
  }
}

SceneView.prototype.addText = function (directive, context) {
  var id = directive.id;
  context.id = id;
  this.lastTextId = id;

  var box = this.textObjects[id];
  if (box) {
    //reuse the DOM element if one of the same ID already exists
    context.oldDirective = box.data("directive");
  }
  else {
    box = $(document.createElement('div')).addClass('bordered dialogue-bubble');
    //attach new div element to the content div
    this.$textContainer.append(box[0]);
    box.data("id", id);
    this.textObjects[id] = box;
  }
  this.applyTextDirective(directive, box);
}

SceneView.prototype.removeText = function (directive, context) {
  this.lastTextId = context.id;
  var box = this.textObjects[directive.id];
  if (context.oldDirective) {
    this.applyTextDirective(context.oldDirective, box);
  }
  else {
    this.$textContainer.get(0).removeChild(box[0]);
    delete this.textObjects[directive.id];
  }
}

SceneView.prototype.applyTextDirective = function (directive, box) {
  var content = expandDialogue(directive.text, null, humanPlayer);

  box.html('<span>' + content + '</span>');
  box.addClass(directive.arrow)
  box.attr('style', directive.css);

  //use css to position the box
  box.css('left', directive.x);
  box.css('top', directive.y);
  box.css('width', directive.width);

  var arrowHeight = (directive.arrow === "arrow-up" || directive.arrow === "arrow-down" ? 15 : 0);
  var arrowWidth = (directive.arrow === "arrow-left" || directive.arrow === "arrow-right" ? 15 : 0);
  switch (directive.alignmenty) {
    case "center":
      var height = box.height() + arrowHeight;
      var top = box.position().top;
      box.css("top", (top - height / 2) + "px");
      break;
    case "bottom":
      var height = box.height() + arrowHeight;
      var top = box.position().top;
      box.css("top", (top - height) + "px");
      break;
  }
  switch (directive.alignmentx) {
    case "center":
      var width = box.width() + arrowWidth;
      var left = box.position().left;
      box.css("left", (left - width / 2) + "px");
      break;
    case "right":
      var width = box.width() + arrowWidth;
      var left = box.position().left;
      box.css("left", (left - width) + "px");
      break;
  }

  box.data("directive", directive);
}

SceneView.prototype.clearAllText = function (directive, context) {
  var $this = this;
  context = context || {};
  for (var box in this.textObjects) {
    this.clearText({ id: this.textObjects[box].data("id") }, context, true);
  }
  this.textObjects = {};
}

SceneView.prototype.clearText = function (directive, context, keepObject) {
  context.boxes = context.boxes || [];
  var boxContext = {};
  context.boxes.push(boxContext);

  var id = directive.id || this.lastTextId;
  boxContext.id = lastTextId = id;
  var box = this.textObjects[id];

  if (!box) {
    return;
  }

  boxContext.directive = box.data("directive");
  this.$textContainer.get(0).removeChild(box[0]);
  if (!keepObject) {
    delete this.textObjects[id];
  }
}

SceneView.prototype.restoreText = function (directive, context) {
  for (var i = 0; i < context.boxes.length; i++) {
    var boxContext = context.boxes[i];
    var id = this.lastTextId = boxContext.id;
    var directive = boxContext.directive;
    directive.id = id;
    this.addText(directive, {});
  }
}

SceneView.prototype.interpolate = function (obj, prop, last, next, t, mode) {
  var current = obj[prop];
  var start = last[prop];
  var end = next[prop];
  if (mode !== "none" && (typeof start === "undefined" || isNaN(start) || typeof end === "undefined" || isNaN(end))) {
    return;
  }
  mode = mode || next.interpolation || "linear";
  obj[prop] = interpolationModes[mode](prop, start, end, t, last.keyframes, last.index);
}

SceneView.prototype.updateObject = function (id, last, next, t) {
  var obj = this.sceneObjects[id];
  obj.interpolateProperties(last, next, t);
}

SceneView.prototype.addAnimation = function (anim) {
  this.anims.push(anim);
  return anim;
}

SceneView.prototype.moveSprite = function (directive, context) {
  var sprite = this.sceneObjects[directive.id];
  if (sprite) {
    var frames = directive.keyframes.slice();
    context.x = sprite.x;
    context.y = sprite.y;
    context.rotation = sprite.rotation;
    context.scalex = sprite.scalex;
    context.scaley = sprite.scaley;
    context.skewx = sprite.skewx;
    context.skewy = sprite.skewy;
    context.alpha = sprite.alpha;
    context.src = sprite.src;
    frames.unshift(context);
    context.anim = this.addAnimation(new Animation(directive.id, frames, this.updateObject.bind(this), directive.loop, directive.ease, directive.clamp, directive.iterations));
  }
}

SceneView.prototype.returnSprite = function (directive, context) {
  var sprite = this.sceneObjects[directive.id];
  if (sprite) {
    if (typeof context.x !== "undefined") {
      sprite.x = context.x;
    }
    if (typeof context.y !== "undefined") {
      sprite.y = context.y;
    }
    if (typeof context.rotation !== "undefined") {
      sprite.rotation = context.rotation;
    }
    if (typeof context.scalex !== "undefined") {
      sprite.scalex = context.scalex;
    }
    if (typeof context.scaley !== "undefined") {
      sprite.scaley = context.scaley;
    }
    if (typeof context.skewx !== "undefined") {
      sprite.skewx = context.skewx;
    }
    if (typeof context.skewy !== "undefined") {
      sprite.skewy = context.skewy;
    }
    if (typeof context.alpha !== "undefined") {
      sprite.alpha = context.alpha;
    }
    if (typeof context.src !== "undefined") {
      sprite.setImage(context.src);
    }
    this.removeAnimation(context.anim);
    this.draw();
  }
}

SceneView.prototype.removeAnimation = function (anim) {
  if (anim) {
    var index = this.anims.indexOf(anim);
    if (index >= 0) {
      this.anims.splice(index, 1);
    }
  }
}

SceneView.prototype.updateCamera = function (id, last, next, t) {
  this.interpolate(this.camera, "x", last, next, t);
  this.interpolate(this.camera, "y", last, next, t);
  if (last.zoom && next.zoom) {
    this.camera.zoom = lerp(last.zoom, next.zoom, t);
  }
}

SceneView.prototype.moveCamera = function (directive, context) {
  var frames = directive.keyframes.slice();
  context.x = this.camera.x;
  context.y = this.camera.y;
  context.zoom = this.camera.zoom;
  frames.unshift(context);
  context.anim = this.addAnimation(new Animation("camera", frames, this.updateCamera.bind(this), directive.loop, directive.ease, directive.clamp, directive.iterations));
}

SceneView.prototype.returnCamera = function (directive, context) {
  if (typeof context.x !== "undefined") {
    this.camera.x = context.x;
  }
  if (typeof context.y !== "undefined") {
    this.camera.y = context.y;
  }
  if (context.zoom) {
    this.camera.zoom = context.zoom;
  }
  this.removeAnimation(context.anim);
  this.draw();
}

SceneView.prototype.fade = function (directive, context) {
  var color = toHex(this.scene.view.overlay.rgb);
  var frames = directive.keyframes.slice();
  context.color = color;
  context.alpha = this.scene.view.overlay.a;
  frames.unshift(context);
  context.anim = this.addAnimation(new Animation("fade", frames, this.updateOverlay.bind(this), directive.loop, directive.ease, directive.clamp, directive.iterations));
}

SceneView.prototype.restoreOverlay = function (directive, context) {
  this.setOverlay(context.color, context.alpha);
  this.removeAnimation(context.anim);
}

SceneView.prototype.isAnimRunning = function () {
  if (this.pendingDirectives.length > 0) {
    return true;
  }
  for (var i = 0; i < this.anims.length; i++) {
    if (!this.anims[i].looped) {
      return true;
    }
  }
  return false;
}

SceneView.prototype.stopAnimation = function (directive, context) {
  var anim;
  var id = directive.id;
  context.haltedAnims = [];
  for (var i = this.anims.length - 1; i >= 0; i--) {
    anim = this.anims[i];
    if (anim.id === id) {
      anim.halt();
      this.anims.splice(i, 1);
      context.haltedAnims.push(anim);
      this.draw();
    }
  }
}

SceneView.prototype.restoreAnimation = function (directive, context) {
  var haltedAnims = context.haltedAnims;
  for (var i = 0; i < haltedAnims.length; i++) {
    var anim = haltedAnims[i];
    anim.elapsed = 0;
    this.addAnimation(anim);
  }
}

SceneView.prototype.addEmitter = function (directive, context) {
  var element;

  if (directive.src) {
    var srcImg = this.assetMap[directive.src];
    directive.width = directive.width || srcImg.naturalWidth;
    directive.height = directive.height || srcImg.naturalHeight;
  }

  this.addSceneObject(new Emitter(directive.id, element, this, directive, this.particlePool));
}

SceneView.prototype.burstParticles = function (directive, context) {
  var emitter = this.sceneObjects[directive.id];
  if (emitter && emitter.emit) {
    context.emitter = emitter;
    for (var i = 0; i < directive.count; i++) {
      emitter.emit();
    }
  }
}

SceneView.prototype.clearParticles = function (directive, context) {
  var emitter = context.emitter;
  if (emitter) {
    context.emitter = emitter;
    for (var i = 0; i < directive.count; i++) {
      emitter.killParticles();
    }
  }
}

function RandomParameter(startValue, endValue) {
  this.start = startValue;
  this.end = endValue;
}

RandomParameter.prototype = {
  get: function () {
    return lerp(this.start, this.end, Math.random());
  }
};

function RandomColor(startValue, endValue) {
  this.start = startValue;
  this.end = endValue;
}

RandomColor.prototype = {
  get: function () {
    var t = Math.random();
    return [lerp(this.start[0], this.end[0], t),
    lerp(this.start[1], this.end[1], t),
    lerp(this.start[2], this.end[2], t)];
  }
};

function TweenableParameter(startValue, endValue, ease) {
  this.start = startValue;
  this.end = endValue;
  this.value = this.start;
}

TweenableParameter.prototype = {
  tween: function (t) {
    this.value = lerp(this.start, this.end, t);
    return this.value;
  }
};

function TweenableColor(startValue, endValue) {
  this.start = startValue;
  this.end = endValue;
  this.value = this.start;
}

TweenableColor.prototype = {
  tween: function (t) {
    var value = [];
    value[0] = lerp(this.start[0], this.end[0], t);
    value[1] = lerp(this.start[1], this.end[1], t);
    value[2] = lerp(this.start[2], this.end[2], t);
    this.value = value;
    return value;
  }
};

function SceneObject(id, element, view, args) {
  var alpha = args.alpha;
  if (typeof alpha === "undefined") {
    alpha = 100;
  }

  this.tweenableProperties = ["x", "y", "rotation", "scalex", "scaley", "alpha", "skewx", "skewy"];
  this.id = id;
  this.x = args.x || 0;
  this.y = args.y || 0;
  this.scalex = args.scalex || 1;
  this.scaley = args.scaley || 1;
  this.skewx = args.skewx || 0;
  this.skewy = args.skewy || 0;
  this.rotation = args.rotation || 0;
  this.alpha = alpha;
  this.view = view;
  this.layer = args.layer;

  var scene = this.view.scene;

  if (element) {
    var vehicle = document.createElement("div");
    vehicle.appendChild(element);

    var pivotX = args.pivotx;
    var pivotY = args.pivoty;
    if (pivotX || pivotY) {
      pivotX = pivotX || "center";
      pivotY = pivotY || "center";
      $(element).css("transform-origin", pivotX + " " + pivotY);
    }
    if (this.layer) {
      $(vehicle).css("z-index", args.layer);
    }

    this.element = vehicle;
    this.rotElement = element;

    var width = args.width;
    var height = args.height;
    var naturalWidth = element.naturalWidth || 100;
    var naturalHeight = element.naturalHeight || 100;

    if (width) {
      if (width.endsWith("%")) {
        this.widthPct = parseInt(width, 10) / 100;
      }
      else {
        this.widthPct = parseInt(width, 10) / scene.width;
      }
      if (!height) {
        this.heightPct = naturalHeight / naturalWidth * this.widthPct * scene.aspectRatio;
      }
    }
    else {
      this.widthPct = naturalWidth / scene.width;
    }
    if (height) {
      if (height.endsWith("%")) {
        this.heightPct = parseInt(height, 10) / 100;
      }
      else {
        this.heightPct = parseInt(height, 10) / scene.height;
      }
      if (!width) {
        this.widthPct = naturalWidth / naturalHeight * this.heightPct / scene.aspectRatio;
      }
    }
    else if (!this.heightPct) {
      this.heightPct = naturalHeight / scene.height;
    }

    this.width = this.widthPct * scene.width;
    this.height = this.heightPct * scene.height;
    $(vehicle).css({
      position: "absolute",
      left: 0,
      top: 0,
      width: this.width,
      height: this.height,
    });
    $(element).css({
      width: this.width,
      height: this.height,
    });
  }
  else {
    this.width = parseInt(args.width, 10) || 10;
    this.height = parseInt(args.height, 10) || 10;
  }
}

SceneObject.prototype = {
  destroy: function () {
    if (this.element) {
      $(this.element).remove();
    }
  },

  update: function (elapsedMs) {
  },

  draw: function () {
    this.view.drawObject(this);
  },

  interpolateProperties: function (last, next, t) {
    for (var i = 0; i < this.tweenableProperties.length; i++) {
      this.view.interpolate(this, this.tweenableProperties[i], last, next, t);
    }

    if (next.src) {
      var oldSrc = this.src;
      this.view.interpolate(this, "src", last, next, t, "none");
      if (oldSrc !== this.src) {
        this.setImage(this.src);
      }
    }
  },

  setImage: function (src) {
    if (!src) return;

    this.rotElement.src = this.view.assetMap[src].src;
    this.src = src;
  },
};

function Emitter(id, element, view, args, pool) {
  SceneObject.call(this, id, element, view, args);
  this.tweenableProperties.push("rate");

  this.pool = pool;
  this.rate = args.rate;
  this.emissionTimer = 0;
  if (this.rate > 0) {
    this.emissionTimer = 1000 / this.rate;
  }
  this.activeParticles = [];
  this.src = args.src;
  this.startScaleX = this.createRandomParameter(args.startscalex, 1, 1);
  this.endScaleX = this.createRandomParameter(args.endscalex, this.startScaleX);
  this.startScaleY = this.createRandomParameter(args.startscaley, 1, 1);
  this.endScaleY = this.createRandomParameter(args.endscaley, this.startScaleY);
  this.startSkewX = this.createRandomParameter(args.startskewx, 1, 1);
  this.endSkewX = this.createRandomParameter(args.endskewx, this.startSkewX);
  this.startSkewY = this.createRandomParameter(args.startskewy, 1, 1);
  this.endSkewY = this.createRandomParameter(args.endskewy, this.startSkewY);
  this.speed = this.createRandomParameter(args.speed, 0, 0);
  this.accel = this.createRandomParameter(args.accel, 0, 0);
  this.forceX = this.createRandomParameter(args.forcex, 0, 0);
  this.forceY = this.createRandomParameter(args.forcey, 0, 0);
  this.startColor = this.createRandomColor(args.startcolor, [255, 255, 255], [255, 255, 255]);
  this.endColor = this.createRandomColor(args.endcolor, this.startColor);
  this.startAlpha = this.createRandomParameter(args.startalpha, 100, 100);
  this.endAlpha = this.createRandomParameter(args.endalpha, this.startAlpha);
  this.startRotation = this.createRandomParameter(args.startrotation, 0, 0);
  this.endRotation = this.createRandomParameter(args.endrotation, this.startRotation);
  this.lifetime = this.createRandomParameter(args.lifetime, 1, 1);
  this.angle = args.angle;
  this.ignoreRotation = args.ignorerotation === "1" || args.ignorerotation === "true";
}
Emitter.prototype = Object.create(SceneObject.prototype);
Emitter.prototype.constructor = Emitter;

Emitter.prototype.destroy = function () {
  SceneObject.prototype.destroy.call(this);
  this.killParticles();
}

Emitter.prototype.killParticles = function () {
  for (var i = 0; i < this.activeParticles.length; i++) {
    var particle = this.activeParticles[i];
    particle.destroy();
  }
}

Emitter.prototype.createRandomParameter = function (value, defaultMin, defaultMax) {
  if (typeof value !== "undefined") {
    var range = value.split(":");
    var min = parseFloat(range[0], 10);
    var max = (range.length > 1 ? parseFloat(range[1], 10) : min);
    if (!isNaN(min) && !isNaN(max)) {
      return new RandomParameter(min, max);
    }
  }
  if (defaultMin instanceof RandomParameter) {
    return defaultMin;
  }
  return new RandomParameter(defaultMin, defaultMax);
};

Emitter.prototype.createRandomColor = function (value, defaultMin, defaultMax) {
  if (typeof value !== "undefined") {
    var range = value.split(":");
    var min = fromHex(range[0]);
    var max = (range.length > 1 ? fromHex(range[1]) : min);
    if (min && max) {
      return new RandomColor(min, max);
    }
  }
  if (defaultMin instanceof RandomColor) {
    return defaultMin;
  }
  return new RandomColor(defaultMin, defaultMax);
};


Emitter.prototype.update = function (elapsedMs) {
  if (this.rate > 0) {
    var cooldown = 1000 / this.rate;
    this.emissionTimer += elapsedMs;
    while (this.emissionTimer >= cooldown) {
      this.emit();
      this.emissionTimer -= cooldown;
    }
  }

  for (var i = this.activeParticles.length - 1; i >= 0; i--) {
    var particle = this.activeParticles[i];
    particle.update(elapsedMs);
    if (particle.isDead()) {
      this.activeParticles.splice(i, 1);
      this.pool.push(particle); //return to the global inactive pool
    }
  }
};

Emitter.prototype.emit = function () {
  var particle = this.getFreeParticle();

  //randomize the rotation by the emission angle range
  var rotation = this.rotation;
  var angle = Math.floor(Math.random() * (this.angle * 2 + 1)) - this.angle;
  rotation += angle;

  particle.spawn(this.x - this.width / 2, this.y - this.height / 2, rotation, {
    src: this.src,
    width: this.width,
    height: this.height,
    duration: this.lifetime.get() * 1000,
    startScaleX: this.startScaleX.get(),
    endScaleX: this.endScaleX.get(),
    startScaleY: this.startScaleY.get(),
    endScaleY: this.endScaleY.get(),
    startSkewX: this.startSkewX.get(),
    endSkewX: this.endSkewX.get(),
    startSkewY: this.startSkewY.get(),
    endSkewY: this.endSkewY.get(),
    speed: this.speed.get(),
    accel: this.accel.get(),
    forceX: this.forceX.get(),
    forceY: this.forceY.get(),
    startColor: this.startColor.get(),
    endColor: this.endColor.get(),
    startAlpha: this.startAlpha.get(),
    endAlpha: this.endAlpha.get(),
    startRotation: this.startRotation.get(),
    endRotation: this.endRotation.get(),
    layer: this.layer,
    ignoreRotation: this.ignoreRotation,
  });
  this.activeParticles.push(particle);
};

Emitter.prototype.getFreeParticle = function () {
  var particle;
  if (this.pool.length === 0) {
    particle = new Particle("particle" + this.pool.length, this.view, {});
    this.pool.push(particle);
    this.view.$canvas.append(particle.element);
  }
  particle = this.pool[0];
  this.pool.splice(0, 1);
  return particle;
};

Emitter.prototype.draw = function () {
  if (this.element) {
    SceneObject.prototype.draw.call(this);
  }

  for (var i = this.activeParticles.length - 1; i >= 0; i--) {
    this.activeParticles[i].draw();
  }
};

function Particle(id, view, args) {
  var element = document.createElement("img");
  SceneObject.call(this, id, element, view, args);
  this.$element = $(this.element);
  this.tweens = {};
}

Particle.prototype = Object.create(SceneObject.prototype);
Particle.prototype.constructor = Particle;

Particle.prototype.spawn = function (x, y, rotation, args) {
  var tweens = this.tweens;

  var particleElem = this.rotElement;
  if (args.src) {
    particleElem.src = args.src;
    particleElem.className = "";
  }
  else {
    particleElem.removeAttribute("src");
    particleElem.className = "particle";
  }

  $(particleElem).css({
    "width": args.width + "px",
    "height": args.height + "px",
    "background-color": "",
  });
  $(this.element).css({
    "z-index": args.layer || "",
    "width": args.width + "px",
    "height": args.height + "px",
  });
  this.width = args.width;
  this.height = args.height;
  this.x = x;
  this.y = y;
  this.elapsed = 0;
  this.duration = args.duration;
  this.ease = args.ease || "smooth";
  this.ignoreRotation = args.ignoreRotation;
  tweens["scalex"] = new TweenableParameter(args.startScaleX, args.endScaleX);
  tweens["scaley"] = new TweenableParameter(args.startScaleY, args.endScaleY);
  tweens["skewx"] = new TweenableParameter(args.startSkewX, args.endSkewX);
  tweens["skewy"] = new TweenableParameter(args.startSkewY, args.endSkewY);
  tweens["alpha"] = new TweenableParameter(args.startAlpha, args.endAlpha);
  tweens["color"] = new TweenableColor(args.startColor, args.endColor);
  tweens["spin"] = new TweenableParameter(args.startRotation, args.endRotation);
  this.scalex = args.startScaleX;
  this.scaley = args.startScaleY;
  this.skewx = args.startSkewX;
  this.skewy = args.startSkewY;
  this.alpha = args.startAlpha;
  this.color = args.startColor;
  this.spin = args.startRotation;

  //initial speed is in the direction of the starting rotation
  this.rotation = this.ignoreRotation ? 0 : rotation;
  var degrees = rotation;
  var radians = degrees * (Math.PI / 180);
  var speed = args.speed;
  this.initialAngle = radians;

  //convert rotation angle to direction vector where 0 deg = [0,-1], 90 deg = [1,0]
  var u = Math.sin(radians);
  var v = -Math.cos(radians);

  this.speedX = speed * u;
  this.speedY = speed * v;

  this.accel = args.accel;
  this.forceX = args.forceX;
  this.forceY = args.forceY;

  this.$element.show();
},

  Particle.prototype.isDead = function () {
    return this.elapsed >= this.duration;
  };

Particle.prototype.die = function () {
  this.$element.hide();
};

Particle.prototype.update = function (elapsedMs) {
  this.elapsed += elapsedMs;
  var dt = elapsedMs / 1000;

  if (this.isDead()) {
    this.die();
  }

  var t = this.elapsed / this.duration;
  t = Animation.prototype.easingFunctions[this.ease](t);
  for (var prop in this.tweens) {
    this[prop] = this.tweens[prop].tween(t);
  }

  this.rotation += this.spin * dt;

  //accelerate in the forward direction
  var forward = this.initialAngle;
  //forward = this.rotation * (Math.PI / 180);
  var u = Math.sin(forward);
  var v = -Math.cos(forward);

  var accelX = this.accel * u;
  var accelY = this.accel * v;

  this.speedX += (accelX + this.forceX) * dt;
  this.speedY += (accelY + this.forceY) * dt;

  this.x += dt * this.speedX;
  this.y += dt * this.speedY;
};

Particle.prototype.draw = function (view) {
  if (!this.rotElement.src) {
    var color = toHex(this.color);
    $(this.rotElement).css({
      "background-color": color,
    });
  }
  SceneObject.prototype.draw.call(this);
};

function SceneTransition(fromView, toView, transitionDirective, overlay) {
  this.view1 = fromView;
  this.view2 = toView;
  this.duration = transitionDirective.time;
  this.elapsed = 0;
  this.overlay = overlay;
  this.overlay.css("background-color", transitionDirective.color);
  this.ease = transitionDirective.ease || "ease-out";
  switch (transitionDirective.effect) {
    case "dissolve":
      this.effect = this.dissolve;
      break;
    case "fade":
      this.effect = this.fade;
      break;
    case "wipe-right":
      this.effect = this.wipeRight;
      break;
    case "wipe-left":
      this.effect = this.wipeLeft;
      break;
    case "wipe-up":
      this.effect = this.wipeUp;
      break;
    case "wipe-down":
      this.effect = this.wipeDown;
      break;
    case "slide-right":
      this.effect = this.slideRight;
      break;
    case "slide-left":
      this.effect = this.slideLeft;
      break;
    case "slide-up":
      this.effect = this.slideUp;
      break;
    case "slide-down":
      this.effect = this.slideDown;
      break;
    case "push-left":
      this.effect = this.pushLeft;
      break;
    case "push-right":
      this.effect = this.pushRight;
      break;
    case "push-up":
      this.effect = this.pushUp;
      break;
    case "push-down":
      this.effect = this.pushDown;
      break;
    case "uncover-left":
      this.effect = this.uncoverLeft;
      break;
    case "uncover-right":
      this.effect = this.uncoverRight;
      break;
    case "uncover-up":
      this.effect = this.uncoverUp;
      break;
    case "uncover-down":
      this.effect = this.uncoverDown;
      break;
    case "barn-open-horizontal":
      this.effect = this.barnOpenHorizontal;
      break;
    case "barn-close-horizontal":
      this.effect = this.barnCloseHorizontal;
      break;
    case "barn-open-vertical":
      this.effect = this.barnOpenVertical;
      break;
    case "barn-close-vertical":
      this.effect = this.barnCloseVertical;
      break;
    case "fly-through":
      this.effect = this.flyThrough;
      break;
    case "spin":
      this.effect = this.spin;
      break;
    default:
      this.effect = this.cut;
      break;
  }
  this.effect(0);
}

SceneTransition.prototype.isComplete = function () {
  return this.elapsed >= this.duration;
}

SceneTransition.prototype.finish = function () {
  var styleReset = {
    "transform": "",
    "clip": "",
    "opacity": "",
  };
  this.view1.$viewport.css(styleReset);
  this.view2.$viewport.css(styleReset);
  this.view1.cleanup();
  this.overlay.css("opacity", "");
}

SceneTransition.prototype.update = function (elapsed) {
  this.elapsed += elapsed;

  if (this.isComplete()) {
    this.finish();
    return;
  }

  var t = Math.min(1, this.elapsed / this.duration);
  if (this.duration === 0) {
    t = 1;
  }
  else {
    var easingFunction = Animation.prototype.easingFunctions[this.ease];
    t = easingFunction(t);
  }
  this.effect(t);
}

SceneTransition.prototype.cut = function (t) {
  this.elapsed = this.duration;
  this.view1.$viewport.hide();
}

SceneTransition.prototype.dissolve = function (t) {
  var viewport1 = this.view1.$viewport;
  var viewport2 = this.view2.$viewport;

  viewport1.css("opacity", 1 - t);
  viewport2.css("opacity", t);
}

SceneTransition.prototype.fade = function (t) {
  var viewport1 = this.view1.$viewport;
  var viewport2 = this.view2.$viewport;

  var alpha = (t <= 0.5 ? t * 2 : (1 - (t - 0.5) * 2));

  this.overlay.css("opacity", alpha);
  viewport1.css("opacity", t < 0.5 ? 1 : 0);
  viewport2.css("opacity", t < 0.5 ? 0 : 1);
}

SceneTransition.prototype.slideRight = function (t) {
  var left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(calc(-50% - " + left + "px), -50%)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, " + left + "px)",
  });
}

SceneTransition.prototype.slideLeft = function (t) {
  var left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + (this.view2.viewportWidth - left) + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.slideUp = function (t) {
  var top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight - top) + "px, 0)",
  });
}

SceneTransition.prototype.slideDown = function (t) {
  var top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(-50%, calc(-50% - " + top + "px)",
    "clip": "rect(" + top + "px, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.wipeLeft = function (t) {
  var left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, " + left + "px)",
  });
}

SceneTransition.prototype.wipeRight = function (t) {
  var left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "clip": "rect(0, " + (this.view2.viewportWidth - left) + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.wipeUp = function (t) {
  var top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "clip": "rect(" + top + "px, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.wipeDown = function (t) {
  var top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight - top) + "px, 0)",
  });
}

SceneTransition.prototype.pushRight = function (t) {
  var left = Math.ceil(this.view1.viewportWidth * t);
  this.view1.$viewport.css({
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + (this.view1.viewportWidth * (1 - t)) + "px, " + this.view1.viewportHeight + "px, 0)",
  });

  left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(calc(-50% - " + left + "px), -50%)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, " + left + "px)",
  });
}

SceneTransition.prototype.pushLeft = function (t) {
  var left = -Math.ceil(this.view1.viewportWidth * t);
  this.view1.$viewport.css({
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + this.view1.viewportWidth + "px, " + this.view1.viewportHeight + "px, " + (-left) + "px)",
  });

  left = Math.ceil(this.view2.viewportWidth * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + (this.view2.viewportWidth - left) + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.pushUp = function (t) {
  var top = -Math.ceil(this.view1.viewportHeight * t);
  this.view1.$viewport.css({
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(" + (-top) + "px, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, 0)",
  });

  var top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight - top) + "px, 0)",
  });
}

SceneTransition.prototype.pushDown = function (t) {
  var top = Math.ceil(this.view1.viewportHeight * t);
  this.view1.$viewport.css({
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight * (1 - t)) + "px, 0)",
  });

  top = Math.ceil(this.view2.viewportHeight * (1 - t));
  this.view2.$viewport.css({
    "transform": "translate(-50%, calc(-50% - " + top + "px)",
    "clip": "rect(" + top + "px, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.uncoverRight = function (t) {
  var left = Math.ceil(this.view1.viewportWidth * t);
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + (this.view1.viewportWidth * (1 - t)) + "px, " + this.view1.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.uncoverLeft = function (t) {
  var left = -Math.ceil(this.view1.viewportWidth * t);
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(calc(-50% + " + left + "px), -50%)",
    "clip": "rect(0, " + this.view1.viewportWidth + "px, " + this.view1.viewportHeight + "px, " + (-left) + "px)",
  });
}

SceneTransition.prototype.uncoverUp = function (t) {
  var top = -Math.ceil(this.view1.viewportHeight * t);
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(" + (-top) + "px, " + this.view2.viewportWidth + "px, " + this.view2.viewportHeight + "px, 0)",
  });
}

SceneTransition.prototype.uncoverDown = function (t) {
  var top = Math.ceil(this.view1.viewportHeight * t);
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(-50%, calc(-50% + " + top + "px)",
    "clip": "rect(0, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight * (1 - t)) + "px, 0)",
  });
}

SceneTransition.prototype.barnOpenHorizontal = function (t) {
  this.view2.$viewport.css({
    "clip": "rect(0, " + (this.view2.viewportWidth / 2 + t * this.view2.viewportWidth / 2) + "px, " + this.view2.viewportHeight + "px, " + (this.view2.viewportWidth / 2 - t * this.view2.viewportWidth / 2) + "px)",
  });
}

SceneTransition.prototype.barnCloseHorizontal = function (t) {
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "clip": "rect(0, " + (this.view2.viewportWidth / 2 + (1 - t) * this.view2.viewportWidth / 2) + "px, " + this.view2.viewportHeight + "px, " + (this.view2.viewportWidth / 2 - (1 - t) * this.view2.viewportWidth / 2) + "px)",
  });
}

SceneTransition.prototype.barnOpenVertical = function (t) {
  this.view2.$viewport.css({
    "clip": "rect(" + (this.view2.viewportHeight / 2 - t * this.view2.viewportHeight / 2) + "px, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight / 2 + t * this.view2.viewportHeight / 2) + "px, 0)",
  });
}

SceneTransition.prototype.barnCloseVertical = function (t) {
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "clip": "rect(" + (this.view2.viewportHeight / 2 - (1 - t) * this.view2.viewportHeight / 2) + "px, " + this.view2.viewportWidth + "px, " + (this.view2.viewportHeight / 2 + (1 - t) * this.view2.viewportHeight / 2) + "px, 0)",
  });
}

SceneTransition.prototype.spin = function (t) {
  var viewport1 = this.view1.$viewport;
  var viewport2 = this.view2.$viewport;

  if (t < 0.5) {
    t *= 2;
    viewport1.css("opacity", 1);
    viewport2.css("opacity", 0);
    this.view1.$viewport.css({
      "transform": "translate(-50%, -50%) rotate(" + t * 1080 + "deg) scale(" + (5 * t + 1) + ")",
    });
  }
  else {
    t = (1 - (t - 0.5) * 2);
    viewport1.css("opacity", 0);
    viewport2.css("opacity", 1);
    this.view2.$viewport.css({
      "transform": "translate(-50%, -50%) rotate(" + t * 1080 + "deg) scale(" + (5 * t + 1) + ")",
    });
  }
}

SceneTransition.prototype.flyThrough = function (t) {
  var zoom = lerp(1, 2, t);
  this.view1.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(-50%, -50%) scale(" + zoom + ")",
    "opacity": (1 - t),
  });
  zoom = lerp(0.5, 1, t);
  this.view2.$viewport.css({
    "z-index": EpiloguePlayer.prototype.layer + 1,
    "transform": "translate(-50%, -50%) scale(" + zoom + ")",
    "opacity": t,
  });
}