Skip to content
Snippets Groups Projects
spniEpilogue.js 88.5 KiB
Newer Older
  • Learn to ignore specific revisions
  • /* 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');
    
    spnati_edit's avatar
    spnati_edit committed
    previousButton.addEventListener('click', function (e) {
    
      e.preventDefault();
      e.stopPropagation();
    
      moveEpilogueBack();
    
    spnati_edit's avatar
    spnati_edit committed
    nextButton.addEventListener('click', function (e) {
    
      e.preventDefault();
      e.stopPropagation();
    
      moveEpilogueForward();
    
    spnati_edit's avatar
    spnati_edit committed
    document.getElementById('epilogue-restart').addEventListener('click', function (e) {
    
      e.preventDefault();
      e.stopPropagation();
      showRestartModal();
    });
    
    spnati_edit's avatar
    spnati_edit committed
    document.getElementById('epilogue-buttons').addEventListener('click', function () {
    
      if (!previousButton.disabled) {
    
        moveEpilogueBack();
    
    spnati_edit's avatar
    spnati_edit committed
    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; },
    
    spnati_edit's avatar
    spnati_edit committed
      "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; },
      "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;
    
        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
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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 = {
    
    spnati_edit's avatar
    spnati_edit committed
      "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;
    
    spnati_edit's avatar
    spnati_edit committed
        var p3 = index < frames.length - 1 ? frames[index + 1][prop] : end;
    
    spnati_edit's avatar
    spnati_edit committed
    
        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
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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
     ************************************************************/
    
    function loadEpilogueData(player) {
    
    spnati_edit's avatar
    spnati_edit committed
      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 [];
      }
    
    spnati_edit's avatar
    spnati_edit committed
      var playerGender = players[HUMAN_PLAYER].gender;
    
    spnati_edit's avatar
    spnati_edit committed
      //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) {
    
    spnati_edit's avatar
    spnati_edit committed
        /* 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(players[HUMAN_PLAYER].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;
        }
    
    spnati_edit's avatar
    spnati_edit committed
        /* '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;
        }
    
    spnati_edit's avatar
    spnati_edit committed
        /* '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;
        }
    
    spnati_edit's avatar
    spnati_edit committed
        // if we made it this far the epilogue must be selectable
        return true;
    
      }).map(function (i, e) { return parseEpilogue(player, e); }).get();
    
    spnati_edit's avatar
    spnati_edit committed
      return epilogues;
    
    spnati_edit's avatar
    spnati_edit committed
    var animatedProperties = ["x", "y", "rotation", "scalex", "scaley", "skewx", "skewy", "alpha", "src", "zoom", "color"];
    
    spnati_edit's avatar
    spnati_edit committed
    
    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);
    
        $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) {
    
    spnati_edit's avatar
    spnati_edit committed
                //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";
              }
    
    
        //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];
    
    spnati_edit's avatar
    spnati_edit committed
      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();
    
    
        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.
    
    spnati_edit's avatar
    spnati_edit committed
        if (x && x.toLowerCase() == "centered") {
    
          x = getCenteredPosition(w);
        }
    
        var text = fixupDialogue($(this).find("content").html().trim()); //the actual content of the text box
    
        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" });
    
    spnati_edit's avatar
    spnati_edit committed
        addedPause = true;
    
    spnati_edit's avatar
    spnati_edit committed
      if (!addedPause) {
    
    spnati_edit's avatar
    spnati_edit committed
        scene.directives.push({ type: "pause" });
    
    spnati_edit's avatar
    spnati_edit committed
      }
    
    spnati_edit's avatar
    spnati_edit committed
    /************************************************************
    * 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;
    
    spnati_edit's avatar
    spnati_edit committed
        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);
    
    spnati_edit's avatar
    spnati_edit committed
        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;
    
    spnati_edit's avatar
    spnati_edit committed
        if (x && x.toLowerCase() == "centered") {
    
          targetObj.x = getCenteredPosition(w);
        }
      }
      return targetObj;
    
    }
    
    /************************************************************
     * Add the epilogue to the Epilogue modal
     ************************************************************/
    
    
    spnati_edit's avatar
    spnati_edit committed
    function addEpilogueEntry(epilogue) {
      var num = epilogues.length; //index number of the new epilogue
      epilogues.push(epilogue);
    
      var player = epilogue.player;
    
    spnati_edit's avatar
    spnati_edit committed
      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
      }
    
    spnati_edit's avatar
    spnati_edit committed
      var epilogueTitle = nameStr + ": " + epilogue.title;
      var idName = 'epilogue-option-' + num;
      var clickAction = "selectEpilogue(" + num + ")";
      var unlocked = save.hasEnding(player.id, epilogue.title) ? " unlocked" : "";
    
    spnati_edit's avatar
    spnati_edit committed
      var htmlStr = '<li id="' + idName + '" class="epilogue-entry' + unlocked + '"><button onclick="' + clickAction + '">' + epilogueTitle + '</button></li>';
    
    spnati_edit's avatar
    spnati_edit committed
      $epilogueList.append(htmlStr);
      epilogueSelections.push($('#' + idName));
    
    }
    
    /************************************************************
     * Clear the Epilogue modal
     ************************************************************/
    
    
    spnati_edit's avatar
    spnati_edit committed
    function clearEpilogueList() {
      $epilogueHeader.html('');
      $epilogueList.html('');
      epilogues = [];
      epilogueSelections = [];
    
    spnati_edit's avatar
    spnati_edit committed
    /************************************************************
     * Cleans up epilogue data
     ************************************************************/
    function clearEpilogue() {
      if (epiloguePlayer) {
        epiloguePlayer.destroy();
        epiloguePlayer = null;
      }
    }
    
    
    /************************************************************
     * The user has clicked on a button to choose a particular Epilogue
     ************************************************************/
    
    
    spnati_edit's avatar
    spnati_edit committed
    function selectEpilogue(epNumber) {
      chosenEpilogue = epilogues[epNumber]; //select the chosen epilogues
    
    spnati_edit's avatar
    spnati_edit committed
      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.
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    function doEpilogueModal() {
    
      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 = !players[HUMAN_PLAYER].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
          }
    
    spnati_edit's avatar
    spnati_edit committed
          headerStr = lossStr; //player lost
    
    spnati_edit's avatar
    spnati_edit committed
      } else {
        if (playerWon) {
          headerStr = winEpiloguesDisabledStr;
        } else {
          headerStr = lossEpiloguesDisabledStr;
        }
      }
    
    spnati_edit's avatar
    spnati_edit committed
      $epilogueHeader.html(headerStr); //set the header string
      $epilogueSelectionModal.modal("show");//show the epilogue selection modal
    
    }
    
    /************************************************************
     * Start the Epilogue
     ************************************************************/
    
    spnati_edit's avatar
    spnati_edit committed
    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
          }
        };
    
        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);
    
    
    spnati_edit's avatar
    spnati_edit committed
      screenTransition($titleScreen, $epilogueScreen); //currently transitioning from title screen, because this is for testing
      $epilogueSelectionModal.modal("hide");
    
    spnati_edit's avatar
    spnati_edit committed
    /************************************************************
    * 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) {
    
      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());
    }
    
    
    spnati_edit's avatar
    spnati_edit committed
    /************************************************************
    * Class for playing through an epilogue
    ************************************************************/
    
    function EpiloguePlayer(epilogue) {
    
    spnati_edit's avatar
    spnati_edit committed
      $(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);
          }