Skip to content
Snippets Groups Projects
spniEpilogue.js 89.6 KiB
Newer Older
/* 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; },
spnati_edit's avatar
spnati_edit committed
  "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;
    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
 ************************************************************/
 
// 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) {
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 [];
  }
  var playerGender = humanPlayer.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(humanPlayer.startingLayers, playerStartingLayers)) {
spnati_edit's avatar
spnati_edit committed
      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() {
  if (SENTRY_INITIALIZED) {
    Sentry.addBreadcrumb({
      category: 'ui',
      message: 'Showing epilogue modal.',
      level: 'info'
    });
  }
spnati_edit's avatar
spnati_edit committed

  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;
spnati_edit's avatar
spnati_edit committed

  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
      }
    };

    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");
spnati_edit's avatar
spnati_edit committed
    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);
spnati_edit's avatar
spnati_edit committed
  $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
************************************************************/