Source

rez_game.js

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

/**
 * @class RezGame
 * @extends RezBasicObject
 * @category Elements
 * @description The central singleton that manages the entire game runtime. RezGame is
 * automatically instantiated with id "game" and is accessible globally via `$game`.
 *
 * RezGame provides:
 * - **Object Registry**: All game objects are registered here and accessible via `$()` or
 *   `getGameObject()`. Objects are indexed by tags and attributes for fast lookup.
 * - **Scene Management**: Controls scene transitions (`startSceneWithId`), interludes
 *   (`interludeSceneWithId`), and resumption (`resumePrevScene`) with a scene stack.
 * - **View System**: Manages the RezView that renders content to the DOM, including
 *   layout management and bound control updates.
 * - **Persistence**: Save/load functionality via `save()` and `load()` methods that
 *   serialize/deserialize all changed game object attributes.
 * - **Undo System**: Tracks attribute changes and object creation/deletion for undo support.
 * - **Flash Messages**: Temporary messages displayed on the next render cycle.
 * - **Systems**: Manages enabled RezSystem objects that hook into game events.
 *
 * The game is started by calling `start(containerId)` which initializes all objects,
 * builds the view, and starts the initial scene.
 */
class RezGame extends RezBasicObject {
  #containerId;
  #undoManager;
  #eventProcessor;
  #tagIndex;
  #attrIndex;
  #gameObjects;
  #view;

  constructor(id, attributes) {
    super("game", id, attributes);

    this.#undoManager = new RezUndoManager();
    this.#eventProcessor = new RezEventProcessor(this);
    this.#tagIndex = {};
    this.#attrIndex = {};
    this.#gameObjects = new Map();
    this.$ = this.getGameObject;
    this.addGameObject(this);
  }

  get undoManager() {
    return this.#undoManager;
  }

  get eventProcessor() {
    return this.#eventProcessor;
  }

  get gameObjects() {
    return this.#gameObjects;
  }

  get objectCount() {
    return this.#gameObjects.size;
  }

  get view() {
    return this.#view;
  }

  bindAs() {
    return "game";
  }

  getViewTemplate() {
    return this.$layout_template;
  }

  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() + 1,
      now.getDate(),
      now.getHours(),
      now.getMinutes(),
      now.getSeconds(),
    ];
    const date = date_parts.map(formatter).join("");
    const casedPrefix = prefix.toSnakeCase();
    return `${casedPrefix}_${date}.json`;
  }

  dataArchive() {
    const archive = {};
    this.gameObjects.forEach((obj, _id) => {
      obj.archiveInto(archive);
    });
    return archive;
  }

  gameArchive() {
    return {
      archive_format: this.archive_format,
      data: this.dataArchive()
    };
  }

  saveData() {
    return JSON.stringify(this.gameArchive());
  }

  get canSave() {
    return this.#view && this.#view.layoutStack.length === 0;
  }

   /**
   * @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() {
    if(!this.canSave) {
      throw new Error("Cannot save game at this time!");
    }

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

    const file = new File(
      [this.saveData()],
      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 archiveFormat = wrapper.archive_format;
    const currentFormat = this.getAttribute("archive_format");

    if(typeof archiveFormat === "undefined") {
      throw new Error("JSON does not represent a Rez game archive!");
    } else if(archiveFormat !== currentFormat) {
      throw new Error(`JSON version v${archiveFormat} different to current v${currentFormat})!`);
    } else {
      console.log(`Matching archive format: ${archiveFormat}`);
    }

    const data = wrapper.data;
    if(typeof data === "undefined") {
      throw new Error("JSON does not contain data archive!");
    } else {
      console.log("Found data");
    }

    // Load the game's attributes and properties
    for(const [id, obj_data] of Object.entries(data)) {
      console.log(`Loading data for ${id}`);
      const obj = this.getGameObject(id);
      obj.loadData(obj_data);
      obj.runEvent("did_load");
    }

    // Restore the game state
    this.runEvent("did_load");

    this.current_scene.resumeFromLoad();
    this.updateViewContent();
    this.updateView();
  }

  /**
   * @function getObjectsWithTag
   * @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
   */
  getObjectsWithTag(tag) {
    const objects = this.#tagIndex[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.
   * @TODO: Consider this may not work with mixed in properties as they do
   * not appear in #attributes
   *
   * @param {basic_object} elem element whose attributes are to be indexed
   */
  addToAttrIndex(elem) {
    if(!elem.isTemplateObject()) {
      Object.entries(elem.attributes).forEach(([k, _v]) => {
        this.indexAttribute(elem.id, k);
      });
    }
  }

  removeFromAttrIndex(elem) {
    if(!elem.isTemplateObject()) {
      Object.entries(elem.attributes).forEach(([k, _v]) => {
        this.unindexAttribute(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(elemId, attrName) {
    const index = this.#attrIndex[attrName] ?? new Set();
    index.add(elemId);
    this.#attrIndex[attrName] = index;
  }

  unindexAttribute(elemId, attrName) {
    const index = this.#attrIndex[attrName];
    if(index !== undefined) {
      index.delete(elemId);

      if(index.size === 0) {
        delete this.#attrIndex[attrName];
      } else {
        this.#attrIndex[attrName] = index;
      }
    }
  }

  /**
   * Return the ids of all game elements having the specified attribute.
   *
   * @function getObjectsWithAttr
   * @param {string} attr_name
   * @returns {Array} matching element ids
   */
  getObjectsWithAttr(attrName) {
    const index = this.#attrIndex[attrName] ?? 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.#tagIndex[tag];
    if(!objects) {
      objects = new Set([obj.id]);
      this.#tagIndex[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) {
    const objects = this.#tagIndex[tag];
    if(objects) {
      objects.delete(obj.id);

      if(objects.size === 0) {
        delete this.#tagIndex[tag];
      }
    }
  }

  /**
   * @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(!(obj instanceof RezBasicObject)) {
      throw new Error("Attempt to register non-game object!");
    }

    this.#gameObjects.set(obj.id, obj);
    this.addToTagIndex(obj);
    this.addToAttrIndex(obj);

    this.#undoManager?.recordNewElement(obj.id);

    return obj;
  }

  unmapObject(obj) {
    if(!(obj instanceof RezBasicObject)) {
      throw new Error("Attempt to unmap non-game object!");
    }

    obj.runEvent("unmap", {});

    if(this.#gameObjects.delete(obj.id)) {
      this.removeFromAttrIndex(obj);
      this.removeFromTagIndex(obj);
    }

    this.#undoManager?.recordRemoveElement(obj);
  }

  /**
   * @function getGameObject
   * @memberof RezGame#
   * @param {string|object} idOrRef either a string ID or a {$ref: "id"} object
   * @param {boolean} should_throw (default: true)
   * @returns {basic_object|undefined} game-object or undefined
   * @description given an element id returns the appropriate game-object reference
   *
   * Accepts both plain string IDs and {$ref: "id"} objects for backward compatibility.
   * If should_throw is true an exception will be thrown if the element id
   * is not valid. Otherwise null is returned.
   */
  getGameObject(idOrRef, shouldThrow = true) {
    const id = Rez.extractId(idOrRef);
    const obj = this.#gameObjects.get(id);
    if(typeof(obj) === "undefined") {
      if(shouldThrow) {
        throw new Error(`No such ID |${id}| found!`);
      } else {
        return undefined;
      }
    }
    return obj;
  }

  /**
   * @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, element, shouldThrow = true) {
    const obj = this.getGameObject(id, shouldThrow);
    if(typeof(obj) !== "undefined" && obj.element !== element) {
      if(shouldThrow) {
        throw new Error(`Game object |${id}| expected to be |${element}| but was |${obj.element}|!`);
      } else {
        return undefined;
      }
    }
    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, attrName, oldValue, newValue) {
    this.undoManager?.recordAttributeChange(elem.id, attrName, oldValue);

    if(this.#view) {
      this.#view.updateBoundControls(elem.id, attrName, newValue);
    }
  }

  /**
   * @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(sourceId, targetId) {
    const relId = `rel_${sourceId}_${targetId}`;
    return this.getTypedGameObject(relId, "relationship", false);
  }

  getRelationshipsOf(sourceId) {
    return this.filterObjects(
      (o) => o.element === "relationship" && o.id.startsWith(`rel_${sourceId}_`)
    );
  }

  getRelationshipsOn(targetId) {
    return this.filterObjects(
      (o) => o.element === "relationship" && o.id.endsWith(`_${targetId}`)
    );
  }

  /**
   * @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.#gameObjects.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(element) {
    if(typeof element === "undefined") {
      return Array.from(this.#gameObjects.values());
    } else {
      return this.filterObjects((obj) => obj.element === element);
    }
  }

  /**
   * @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(sceneId, params = {}) {
    // current_scene is a Rez attribute defined by @scene

    if(this.current_scene) {
      this.runEvent("scene_did_end", {});
      this.current_scene.finish();
    }

    const scene = this.getTypedGameObject(sceneId, "scene", true);

    this.current_scene = scene;

    this.updateViewContent();

    this.clearFlashMessages();
    this.runEvent("scene_will_start", params);
    scene.start(params);
    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(sceneId, params = {}) {
    // current_scene is a Rez attribute defined by @scene

    this.runEvent("scene_will_pause", {});
    this.pushScene();

    const scene = this.getTypedGameObject(sceneId, "scene", true);

    this.current_scene = scene;

    this.updateViewContent();

    this.clearFlashMessages();
    this.runEvent("scene_will_start", params);
    scene.start(params);
    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 = {}) {
    if(!this.canResume()) {
      throw new Error("Cannot resume without a scene on the stack!");
    } else {
      // Let the interlude know we're done
      this.runEvent("scene_did_end", {});
      this.current_scene.finish();
      this.popScene(params);
      this.runEvent("scene_did_resume", {});

      const layout = this.current_scene.getViewLayout();
      // Merge any new params into the existing params
      layout.params = {...layout.params, ...params};
      this.updateView();
    }
  }

  get canUndo() {
    return this.#undoManager.canUndo;
  }

  undo() {
    if(this.canUndo) {
      this.#undoManager.undo();
    }
  }

  /**
   * 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.addLayoutContent(content);
  }

  updateViewContent(params = {}) {
    const layout = this.current_scene.getViewLayout();
    layout.params = params;
    this.setViewContent(layout);
  }

  /**
   * @function updateView
   * @memberof RezGame#
   * @description re-renders the view calling 'will_render' and 'did_render'
   * event handlers on both game and current scene
   */
  updateView() {
    this.runEvent("will_render", {});
    this.current_scene?.runEvent("will_render", {});
    this.current_scene?.current_card?.runEvent("will_render", {});

    this.#view.update();

    this.current_scene?.current_card?.runEvent("did_render", {});
    this.current_scene?.runEvent("did_render", {});
    this.runEvent("did_render", {});
    this.clearFlashMessages();
  }

  restoreView(view) {
    this.#view = view;
    this.updateView();
  }

  /**
   * @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() {
    // current_scene is an attribute defined on @game
    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 installWindowEvents
   * @memberof RezGame#
   * @description Reads the $window_events attribute and installs window-level event
   * listeners that route through the event processor's custom event handling.
   * Each event name in the list (e.g. "wheel") maps to a handler named
   * "on_window_<event_name>" (e.g. "on_window_wheel").
   */
  installWindowEvents() {
    const windowEvents = this.getAttributeValue("$window_events", []);
    const eventProcessor = this.#eventProcessor;

    windowEvents.forEach((eventName) => {
      const listener = (browserEvt) => {
        const result = eventProcessor.raiseWindowEvent(eventName, browserEvt);
        eventProcessor.dispatchResponse(result);
      };
      window.addEventListener(eventName, listener, {passive: false});
    });
  }

  /**
   * @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(containerId) {
    console.log("> Game.start");

    this.#containerId = containerId;

    // Initialize the game objects starting with #game
    this.init();
    const game_objects = this.getAttribute("$init_order");
    game_objects.forEach(function (obj_id) {
      const obj = this.getGameObject(obj_id);
      obj.init();
    }, this);

    this.initMods();

    // Now everything is guaranteed to be initialized, give
    // each object a chance to respond to the game being about
    // to start
    this.getAll().forEach((obj) => {
      obj.runEvent("game_will_start", {});
    });

    this.buildView();
    this.installWindowEvents();
    this.startSceneWithId(this.initial_scene_id);

    this.runEvent("game_did_start", {});
  }

  initMods() {
    for(const {name, initFn} of Rez.mods) {
      try {
        console.log(`Init mod: ${name}`);
        initFn(this);
      } catch (e) {
        console.error(`Error initializing mod ${name}: ${e}`);
      }
    }
  }

  /**
   * Assigns the #view private attribute with a RezView that is initialized
   * with a single layout.
   */
  buildView() {
    this.#view = new RezView(
      this.#containerId,
      this.#eventProcessor,
      new RezSingleLayout("game", this)
    );
  }

  /**
   * @function getEnabledSystems
   * @memberof RezGame#
   * @returns {array} all 'system' game-objects with attribute enabled=true
   */
  getEnabledSystems() {
    const filter = (o) => o.element === "system" && o.getAttributeValue("enabled");
    const order = (sys_a, sys_b) => sys_b.getAttributeValue("priority") - sys_a.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.Rez.RezGame = RezGame;