Source: rez_game.js

//-----------------------------------------------------------------------------
// Game
//-----------------------------------------------------------------------------

/**
 * @class
 * @param {string} id
 * @param {object} attributes
 * @description Represents the singleton @game instance
 */
function RezGame(id, attributes) {
  this.id = id;
  this.undo_manager = new RezUndoManager();
  this.event_processor = new RezEventProcessor(this);
  this.game_object_type = "game";
  this.attributes = attributes;
  this.tag_index = {};
  this.attr_index = {};
  this.wmem = { game: this };
  this.game_objects = new Map();
  this.properties_to_archive = [
    "wmem",
    "tag_index",
    "attr_index"
  ];
  this.changed_attributes = [];
  this.$ = this.getGameObject;
  this.addGameObject(this);
}

RezGame.prototype = {
  __proto__: basic_object,
  constructor: RezGame,

  targetType: "game",

  /**
   * @memberof RezGame
   * @property {RezUndoManager} undoManager the game undo manager
   */
  get undoManager() {
    return this.undo_manager;
  },

  /**
   * @function bindAs
   * @memberof RezGame
   * @returns {string} default binding name for this object
   */
  bindAs() {
    return "game";
  },

  /**
   * @function getViewTemplate
   * @memberof RezGame
   * @returns {*} the layout template object, need to check what type that is
   */
  getViewTemplate() {
    return this.$layout_template;
  },

  /**
   * @function initLevels
   * @memberof RezGame
   * @returns {Array} array of init levels to run (e.g. [0, 1, ..])
   */
  initLevels() {
    return [0, 1, 2, 3];
  },

  /**
   * @function saveFileName
   * @memberof RezGame
   * @param {string} prefix (defaults to game name)
   * @returns {string} file name
   * @description generates a save file name using a prefix & a date-time with
   * a .json extension.
   */
  saveFileName(prefix) {
    if(typeof(prefix) === "undefined") {
      prefix = this.name;
    }
    const now = new Date();
    const formatter = (num) => {
      return String(num).padStart(2, "0");
    };
    const date_parts = [
      now.getFullYear() - 2000,
      now.getMonth(),
      now.getDate(),
      now.getHours(),
      now.getMinutes(),
      now.getSeconds(),
    ];
    const date_mapped = date_parts.map(formatter);
    const date_joined = date_mapped.join("");
    return prefix.toSnakeCase() + "_" + date_joined + ".json";
  },

  /**
   * @function dataWithArchivedObjects
   * @memberof RezGame
   * @param {object} data data archive to append objects to
   * @returns {object} modified data archive
   */
  dataWithArchivedObjects(data) {
    console.dir(this);
    console.log("Checking " + this.game_objects.size + " objects.");
    this.game_objects.forEach(function (obj, id) {
      console.log(id + " -> " + obj.needsArchiving());
      if (obj.needsArchiving()) {
        data["objs"] = data["objs"] || {};
        data["objs"][obj.id] = obj;
      }
    });
    console.log("Done");
    return data;
  },

  /**
   * @function toJSON
   * @memberof RezGame
   * @returns {object} a versioned JSON archive
   */
  toJSON() {
    let data = this.archiveDataContainer();
    data = this.dataWithArchivedAttributes(data);
    data = this.dataWithArchivedProperties(data);
    data = this.dataWithArchivedObjects(data);

    return {
      rez_archive: this.archive_format,
      data: data,
    };
  },

  /**
   * @function archive
   * @memberof RezGame
   * @returns {string} JSON formatted archive string
   */
  archive() {
    const archived = {};

    return JSON.stringify(this, function (key, value) {
      console.log("archive: [" + key + "]");

      if (key == "" || value == null) {
        // This is the game itself
        archived["game"] = true;
        return value;
      } else if (isGameObject(value)) {
        // This is a game object
        const goid = value.id; // GameObjectID
        console.log("<- is a game object: " + goid);
        if (archived[goid]) {
          console.log("<- is already archived");
          return {
            json$safe: true,
            type: "ref",
            game_object_type: value.game_object_type,
            game_object_id: value.id,
          };
        } else {
          console.log("<- archived");
          archived[goid] = true;
          return value;
        }
      } else if (isObject(value)) {
        return value.obj_map((v) => {
          if (isGameObject(v)) {
            return {
              json$safe: true,
              type: "ref",
              game_object_type: value.game_object_type,
              game_object_id: value.id,
            };
          } else {
            return v;
          }
        });
      } else if (typeof value == "function") {
        return {
          json$safe: true,
          type: "function",
          value: value.toString(),
        };
      } else {
        console.log("<- value:" + value);
        return value;
      }
      return value;
    });
  },

  /**
   * @function save
   * @memberof RezGame
   * @description triggers a download of the game archive
   *
   * This uses a hidden link with a 'download' attribute. The link is "clicked"
   * triggering the download of the JSON file. A timeout is used to remove the
   * link.
   */
  save() {
    this.getAll().forEach((obj) => {obj.runEvent("save_game")});

    const file = new File(
      [this.archive()],
      this.saveFileName(this.getAttribute("name")),
      { type: "application/json" }
    );
    const link = document.createElement("a");
    link.style.display = "none";
    link.href = URL.createObjectURL(file);
    link.download = file.name;
    document.body.appendChild(link);
    link.click();
    setTimeout(() => {
      URL.revokeObjectURL(link.href);
      link.parentNode.removeChild(link);
    }, 0);
  },

  /**
   * @function load
   * @memberof RezGame
   * @param {string} source JSON format source archive
   * @description given a JSON source archive restore the game state to what was archived.
   */
  load(source) {
    const wrapper = JSON.parse(source);

    const archive_version = wrapper["rez_archive"];
    if (typeof archive_version == "undefined") {
      throw "JSON does not represent a Rez game archive!";
    } else if (archive_version != this.getAttribute("archive_format")) {
      throw (
        "JSON is v" +
        archive_version +
        " which is not supported (v" +
        this.getAttribute("archive_format") +
        ")!"
      );
    }

    const data = wrapper["data"];
    if (typeof data == "undefined") {
      throw "JSON does not contain data archive!";
    }

    // Load the game's attributes and properties
    this.loadData(data);

    const objs = data["objs"];
    if (typeof objs == "object") {
      for (const [id, obj_data] of Object.entries(objs)) {
        const obj = this.getGameObject(id);
        obj.loadData(obj_data);
      }
    }

    this.getAll().forEach((obj) => obj.runEvent("game_loaded"));
  },

  /**
   * @function getTaggedWith
   * @memberof RezGame
   * @param {string} tag
   * @returns {array} array of indexed game-objects that have the specified tag
   * @description returns all game-objects tagged with the specified tag
   */
  getTaggedWith(tag) {
    const objects = this.tag_index[tag];
    if (objects) {
      return Array.from(objects);
    } else {
      return [];
    }
  },

  /**
   * For each attribute defined on this game object, add it to the game-wide
   * index for that attribute.
   *
   * @param {basic_object} elem element whose attributes are to be indexed
   */
  addToAttrIndex(elem) {
    Object.entries(elem.attributes).forEach(([k, v]) => {
      this.indexAttribute(elem.id, k);
    });
  },

  /**
   * Adds the element to the per-attribute index.
   *
   * @param {string} elem_id id of element to add to the per-attr index
   * @param {string} attr_name
   */
  indexAttribute(elem_id, attr_name) {
    let index = this.attr_index[attr_name] ?? new Set();
    index.add(elem_id);
    this.attr_index[attr_name] = index;
  },

  /**
   * Return the ids of all game elements having the specified attribute.
   *
   * @param {string} attr_name
   * @returns {Array} matching element ids
   */
  getObjectsWith(attr_name) {
    const index = this.attr_index[attr_name] ?? new Set();
    return Array.from(index);
  },

  /**
   * @function indexObjectForTag
   * @memberof RezGame
   * @param {object} obj reference to a game-object
   * @param {string} tag
   * @description applies the specified tag to the spectified game-object
   */
  indexObjectForTag(obj, tag) {
    let objects = this.tag_index[tag];
    if (!objects) {
      objects = new Set([obj.id]);
      this.tag_index[tag] = objects;
    } else {
      objects.add(obj.id)
    }
  },

  /**
   * @function unindexObjectForTag
   * @memberof RezGame
   * @param {object} obj reference to a game-object
   * @param {string} tag a tag to remove
   * @description removes the specified tag from the specified game-object
   */
  unindexObjectForTag(obj, tag) {
    let objects = this.tag_index[tag];
    if (objects) {
      objects.delete(obj.id);
    }
  },

  /**
   * @function addToTagIndex
   * @memberof RezGame
   * @param {object} obj game-object
   * @description indexes the specified game-object for all tags in it's tags attribute
   */
  addToTagIndex(obj) {
    const tags = obj.getAttributeValue("tags", new Set());
    tags.forEach((tag) => this.indexObjectForTag(obj, tag));
  },

  /**
   * @function removeFromTagIndex
   * @memberof RezGame
   * @param {object} obj game-object
   * @description unindexes the specified object from all tags in its tags attribute
   */
  removeFromTagIndex(obj) {
    const tags = obj.getAttributeValue("tags", new Set());
    tags.forEach((tag) => this.unindexObjectForTag(obj, tag));
  },

  /**
   * @function addGameObject
   * @memberof RezGame
   * @param {object} obj game-object
   * @description adds an object representing a game element to the game world and automatically tagging it by its attributes
  */
  addGameObject(obj) {
    if (!isGameObject(obj)) {
      console.dir(obj);
      throw "Attempt to register non-game object!";
    }

    obj.game = this;
    this.game_objects.set(obj.id, obj);
    this.addToTagIndex(obj);
    this.addToAttrIndex(obj);
  },

  /**
   * @function getGameObject
   * @memberof RezGame
   * @param {string} id id of game-object
   * @param {boolean} should_throw (default: true)
   * @returns {basic_object|null} game-object or null
   * @description given an element id returns the appropriate game-object reference
   *
   * If should_throw is true an exception will be thrown if the element id
   * is not valid. Otherwise null is returned.
   */
  getGameObject(id, should_throw = true) {
    if (!this.game_objects.has(id)) {
      if (should_throw) {
        throw "No such ID |" + id + "| found!";
      } else {
        return null;
      }
    } else {
      return this.game_objects.get(id);
    }
  },

  /**
   * @function getTypedGameObject
   * @memberof RezGame
   * @param {string} id id of game-object
   * @param {string} type game object type (e.g. 'actor' or 'item')
   * @param {boolean} should_throw (default: true)
   * @returns {basic_object|null} game-object or null
   */
  getTypedGameObject(id, type, should_throw = true) {
    const obj = this.getGameObject(id, type, should_throw);
    if(obj.game_object_type !== type) {
      if(should_throw) {
        throw `Game object |${id}| was expected to have type |${type}| but found |${obj.game_object_type}|!`
      } else {
        return null;
      }
    } else {
      return obj;
    }
  },

  /**
   * @function elementAttributeHasChanged
   * @memberof RezGame
   * @param {object} elem reference to game-object
   * @param {string} attr_name name of the attribute whose value has changed
   * @param {*} old_value value of the attribute before the change
   * @param {*} new_value value of the attribute after the change
   * @description should be called whenever an attribute value is changed
   *
   * Currently this function notifies the undo manager and the view
   */
  elementAttributeHasChanged(elem, attr_name, old_value, new_value) {
    if(this.undoManager) {
      this.undoManager.recordChange(elem.id, attr_name, old_value);
    }

    if(this.view) {
      this.view.updateBoundControls(elem.id, attr_name, new_value);
    }
  },

  /**
   * @function getRelationship
   * @memberof RezGame
   * @param {string} source_id id of game-object that holds the relationship
   * @param {string} target_id id of game-object to which the relationship refers
   * @returns {RezRelationship|null} the relationship object for this relationship
   * @description we can cheat looking up a relationship because we know how their IDs
   * are constructed.
   *
   * Note that in Rez relationships are unidirectional so that getRelationship("a", "b")
   * and getRelationship("b", "a") are different RezRelationship objects.
   */
  getRelationship(source_id, target_id) {
    const rel_id = "rel_" + source_id + "_" + target_id;
    return this.getGameObject(rel_id, false);
  },

  /**
   * @function filterObjects
   * @memberof RezGame
   * @param {function} pred predicate to filter with
   * @returns {array} game-objects passing the filter
   * @description filters all game-objects returning those for which the pred filter returns true
   */
  filterObjects(pred) {
    return Array.from(this.game_objects.values()).filter(pred);
  },

  /**
   * @function getAll
   * @memberof RezGame
   * @param {string} target_type (optional) a specific game object type (e.g. 'actor', 'item')
   * @returns {array} game-objects with the specified type
   * @description filters all game-objects returning those with the specified type
   */
  getAll(target_type) {
    if(target_type === undefined) {
      return Array.from(this.game_objects.values());
    } else {
      return this.filterObjects((obj) => obj.game_object_type == target_type);
    }
  },

  /**
   * @function startSceneWithId
   * @memberof RezGame
   * @param {string} scene_id id of scene game-object
   * @param {object} params data to pass to the new scene
   * @description finish the current scene and start the new scene with the given id
   */
  startSceneWithId(scene_id, params = {}) {
    if (scene_id == null) {
      throw "new_scene_id cannot be null!";
    }

    if (this.current_scene) {
      this.current_scene.finish();
    }

    const scene = $(scene_id);
    if(scene.game_object_type !== "scene") {
      throw `Attempt to switch scene to game object |${scene_id}| which is a |${scene.game_object_type}|!`;
    }

    this.current_scene = scene;

    const layout = scene.getViewLayout();
    layout.assignParams(params);

    this.setViewContent(scene.getViewLayout());
    this.clearFlashMessages();
    scene.start();
    scene.ready();
  },

  /**
   * @function interludeSceneWithId
   * @memberof RezGame
   * @param {string} scene_id
   * @param {object} params data to pass to the new scene
   * @description interrupts the current scene, pushing it to the scene stack, and then starts the new scene with the given id
   */
  interludeSceneWithId(scene_id, params = {}) {
    console.log(`Interlude from |${this.current_scene_id}| to |${scene_id}|`);

    // Save the state of the current scene
    this.pushScene();

    const scene = $(scene_id);
    if(scene.game_object_type !== "scene") {
      throw `Attempt to interlude to game object |${new_scene_id}| which is a |${scene.game_object_type}|!`;
    }

    this.current_scene = scene;

    const layout = scene.getViewLayout();
    layout.assignParams(params);

    this.setViewContent(scene.getViewLayout());
    this.clearFlashMessages();

    scene.start();
    scene.ready();
  },

  /**
   * @function resumePrevScene
   * @memberof RezGame
   * @param {object} params data to pass back to the previous scene
   * @description finishes the current scene, then pops the previous scene from the scene stack and resumes it
   */
  resumePrevScene(params = {}) {
    console.log(`Resume from |${this.current_scene_id}|`);
    if (!this.canResume()) {
      throw "Cannot resume without a scene on the stack!";
    } else {
      // Let the interlude know we're done
      this.current_scene.finish();
      this.popScene(params);
      this.current_scene.getViewLayout().assignParams(params);
      this.updateView();
    }
  },

  /**
   * Informs the view of new content to be rendered. It is left up to the view
   * & its layout to determine how this affects any existing content of the view.
   *
   * @memberof RezGame
   * @param {Object} content block to be added to the view
   */
  setViewContent(content) {
    this.view.getLayout().addContent(content);
  },

  /**
   * @function getTarget
   * @memberof RezGame
   * @param {string} target_id
   * @returns {object}
   * @description it looks like this was a holdover from when we didn't add the game
   * object to itself under the "game" id.
   * @deprecated
   */
  getTarget(target_id) {
    if (target_id == this.id) {
      return this;
    } else {
      return this.getGameObject(target_id);
    }
  },

  /**
   * @function updateView
   * @memberof RezGame
   * @description re-renders the view calling the 'before_render' and 'after_render'
   * game event handlers
   */
  updateView() {
    console.log("Updating the view");
    this.runEvent("before_render", {});
    this.view.update();
    this.runEvent("after_render", {});
  },

  /**
   * @function canResume
   * @memberof RezGame
   * @returns {boolean}
   * @description returns true if there is at least one scene in the scene stack
   */
  canResume() {
    return this.$scene_stack.length > 0;
  },

  /**
   * @function pushScene
   * @memberof RezGame
   * @description interrupts the current scene and puts it on the scene stack
   */
  pushScene() {
    this.current_scene.interrupt();
    this.$scene_stack.push(this.current_scene_id);
    this.view.pushLayout(new RezSingleLayout("scene", this));
  },

  /**
   * @function popScene
   * @memberof RezGame
   * @param {object} params data to be passed to the scene being resumed
   * @description removes the top object of the scene stack and makes it the current scene
   */
  popScene(params = {}) {
    this.view.popLayout();
    this.current_scene_id = this.$scene_stack.pop();
    this.current_scene.resume(params);
  },

  /**
   * @function setViewLayout
   * @memberof RezGame
   * @param {*} layout ???
   * @description ???
   */
  setViewLayout(layout) {
    this.view.setLayout(layout);
  },

  /**
   * @function start
   * @memberof RezGame
   * @param {string} container_id id of the HTML element into which game content is rendered
   * @description called automatically from the index.html this runs init on the registered game
   * objects then starts the view and starts the initial scene
   */
  start(container_id) {
    console.log("> Game.start");

    // Init every object, will also trigger on_init for any object that defines it
    for (let init_level of this.initLevels()) {
      console.log("init/" + init_level);

      this.init(init_level);

      const game_objects = this.getAttribute("$init_order");

      this.$init_order.forEach(function (obj_id) {
        const obj = this.getGameObject(obj_id);
        obj.init(init_level);
      }, this);
    }

    this.getAll().forEach((obj) => {
      obj.runEvent("game_started", {})
    });

    this.view = new RezView(
      container_id,
      this.event_processor,
      new RezSingleLayout("game", this)
    );

    this.startSceneWithId(this.initial_scene_id);
  },

  /**
   * @function getEnabledSystems
   * @memberof RezGame
   * @returns {array} all 'system' game-objects with attribute enabled=true
   */
  getEnabledSystems() {
    const filter = (o) => o.game_object_type === "system" && o.getAttributeValue("enabled");
    const order = (sys1, sys2) => sys1.getAttributeValue("priority") > sys2.getAttributeValue("priority");
    return this.filterObjects(filter).sort(order);
  },

  /**
   * @function addFlashMessage
   * @memberof RezGame
   * @param {string} message
   * @description adds the given message to the flash to be displayed on the next render
   */
  addFlashMessage(message) {
    this.$flash_messages.push(message);
  },

  /**
   * @function clearFlashMessages
   * @memberof RezGame
   * @description empties the flash messages
   */
  clearFlashMessages() {
    this.$flash_messages = [];
  },
};

window.RezGame = RezGame;