Pre-release
AdventureJS Docs Downloads
Score: 0 Moves: 0
// HintManager.js

(function () {
  /* global adventurejs A */

  /**
   *
   * @class adventurejs.HintManager
   * @ajsnavheading FrameworkClasses
   * @param {Game} game A reference to the game instance.
   * @summary Manages the display of the hint system.
   * @classdesc
   * <p>
   * <strong>HintManager</strong> manages the job of displaying hints.
   * It contains all the methods needed to create the
   * Hint pop-up screen.
   * </p>
   * <p>
   * HintManager is created automatically
   * by {@link adventurejs.Game|Game}. This is an internal class that
   * authors should not need to construct or modify. However, if
   * you'd like to try, you can find styles for the Hint
   * pop-ups in <a href="/css/adventurejs.css">adventurejs.css</a>.
   * All relevant styles are prefixed with '.hint_'.
   * </p>
   */
  class HintManager {
    constructor(game) {
      /**
       * A reference back to the main {@link adventurejs.Game|Game} object.
       * @var {Object} adventurejs.HintManager#game
       * @default {}
       */
      this.game = game;

      /**
       * Used to manage the drawing of the hint dialog.
       * @var {Object} adventurejs.HintManager#initialized
       * @default false
       */
      this.initialized = false;

      /**
       * Div element to contain the Hint dialog.
       * @var {HTMLElement} adventurejs.HintManager#hint_dialog
       * @default {}
       */

      // MODAL VERSION
      // this.hint_dialog = document.createElement("dialog");

      // NON-MODAL VERSION
      this.hint_dialog = document.createElement("div");
      this.hint_dialog.setAttribute("aria-hidden", true);
      this.hint_dialog.setAttribute("aria-modal", true);
      this.hint_dialog.role = "dialog";
      this.hint_dialog.style.display = "none";
      this.hint_dialog.tabIndex = "1";

      this.hint_dialog.classList.add("ajs-hint-dialog", "ajs-dialog");
      this.hint_dialog.setAttribute(
        "aria-labelledby",
        `${this.game.game_name}-hint-dialog-title`
      );
      this.game.display.dialogsEl.appendChild(this.hint_dialog);

      // Source HTML is stored in hintManager.html
      this.hint_dialog.innerHTML = `
        <div class="ajs-hint-dialog-outer ajs-dialog-outer">
          <div class="ajs-hint-dialog-inner ajs-dialog-inner">

            <h2 id="${this.game.game_name}-hint-dialog-title" class="ajs-hint-dialog-title ajs-dialog-title">HINTS</h2>

            <div class="ajs-hint-dialog-content ajs-dialog-content">

              <!-- put content here -->
              <div class="ajs-hint-list-container">
                <div id="${this.game.game_name}-hint-list">
                </div>              
              </div>


              <div class="ajs-hint-dialog-buttons ajs-dialog-button-container">
                <button
                  id="${this.game.game_name}-hint-dialog-cancel-button"
                  class="ajs-hint-dialog-cancel-button ajs-cancel-button ajs-button ajs-dialog-button button-secondary"
                  type="button"
                  value="Cancel"
                  name="hintCancel"
                >
                  Exit
                </button>
              </div>
              <!-- /ajs-hint-dialog-buttons -->

            </div>
            <!-- /ajs-hint-dialog-content -->

          </div> 
          <!-- /ajs-hint-dialog-inner -->
        </div>
        <!-- /ajs-hint-dialog-outer -->
      `;

      // hint_list is where we'll put our text
      this.hint_list = this.hint_dialog.querySelector(
        `#${this.game.game_name}-hint-list`
      );

      // --------------------------------------------------
      // EXIT BUTTON
      // --------------------------------------------------

      /**
       * Button element to dispel the Hint dialog.
       * @var {HTMLElement} adventurejs.HintManager#hintCancel
       * @default {}
       */
      this.hintCancel = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-hint-dialog-cancel-button`
      );
      this.hintCancel.manager = this;
      this.hintCancel.addEventListener("click", function () {
        this.manager.clickClose();
      });

      this.handleEscape = this.handleEscape.bind(this);
    } // constructor

    /**
     * Function that gets called by the Close button.
     * @memberOf adventurejs.HintManager
     * @method adventurejs.HintManager#clickClose
     * @kind function
     */
    clickClose() {
      this.closeDialog();
      var msg = "Hints closed.";
      this.game.print(msg);
      return;
    }

    /**
     * Close the Hint modal dialog.
     * @memberOf adventurejs.HintManager
     * @method adventurejs.HintManager#closeDialog
     * @kind function
     */
    closeDialog() {
      document.removeEventListener("keyup", this.handleEscape);
      this.hint_dialog.classList.remove("active");

      // MODAL VERSION
      // this.hint_dialog.close();

      // NON-MODAL VERSION
      document.activeElement.blur();
      this.hint_dialog.setAttribute("aria-hidden", true);
      setTimeout(() => {
        this.hint_dialog.style.display = "none";
        this.game.display.contentEl.setAttribute("aria-hidden", false);
        this.game.display.contentEl.removeAttribute("inert");
        // set focus on input
        this.game.display.inputEl.focus();
      }, 250);
    }

    /**
     * Prep hint data for display in the hints modal.
     * @memberOf adventurejs.HintManager
     * @method adventurejs.HintManager#drawHints
     * @kind function
     */
    drawHints(hints = this.game.hintcard.hints, html = "") {
      let singles = 0;
      let revealed = 0;
      let o = this.game.settings.obfuscate_hints;
      let count = 0;

      for (let item in hints) {
        let hint = hints[item];
        count++;

        if (!hint.active) continue;

        let is_group = Object.keys(hint.hints).length > 0 ? true : false;

        if (is_group) {
          html += `
            <details class="ajs-hint-group" data-unrevealed="${hint.unrevealed}" data-key="${hint.key}">
            <summary>${o ? A.deobfuscate(hint.name) : hint.name}`;
          if (hint.unrevealed) {
            html += `<span class="ajs-hint-unrevealed" data-unrevealed="${hint.unrevealed}">${hint.unrevealed}</span>`;
          }
          html += `</summary>`;

          if (hint.text) {
            html += `
              <div class="ajs-hint-description">${o ? A.deobfuscate(hint.text) : hint.text}</div>
            `;
          }
          html += `<div class="ajs-nested-hints">`;
          let [nested_html, nested_count, nested_revealed] = this.drawHints(
            hint.hints
          );
          html += nested_html;
          if (nested_count && nested_count > nested_revealed) {
            html += `
              <div class="ajs-hinteractive">
                <button data-total="${nested_count}" class="ajs-button ajs-hint-button button-primary" onclick="window.${this.game.game_name}.hintManager.revealHint(event)">Show Hint <span class="bold next">${nested_revealed + 1}</span> of <span class="bold">${nested_count}</span></button>
              </div>
            `;
          }
          html += `</div>`;
          html += `</details>`;
        } // is_group

        if (!is_group) {
          singles++;
          if (hint.revealed) revealed++;
          html += `
            <div class="ajs-hint ${hint.revealed ? "revealed" : ""}" data-key="${hint.key}">${count}. ${o ? A.deobfuscate(hint.text) : hint.text}</div>
          `;
        } // !is_group
      }

      return [html, singles, revealed];
    }

    /**
     * Function that closes the dialog on escape keyup.
     * @param {*} event
     */
    handleEscape(event) {
      var key = event.which || event.keyCode;
      if (key === 27) {
        event.stopPropagation();
        this.game.hintManager.clickClose(this);
      }
    }

    /**
     * Open the Hint modal dialog.
     * @memberOf adventurejs.HintManager
     * @method adventurejs.HintManager#openDialog
     * @kind function
     */
    openDialog() {
      // if (!this.initialized) {
      if (this.game.hintcard.introduction) {
        this.hint_list.innerHTML = `
            <div class="ajs-hint-introduction">
              ${this.game.hintcard.introduction}
            </div>
          `;
      }
      this.hint_list.innerHTML = this.hint_list.innerHTML + this.drawHints()[0];
      this.placeButtons();
      this.initialized = true;
      // }

      document.addEventListener("keyup", this.handleEscape);

      // activate dialog
      this.hint_dialog.classList.add("active");

      // MODAL VERSION
      // this.hint_dialog.showModal();

      // NON-MODAL VERSION
      // deactivate main content
      document.activeElement.blur();
      this.game.display.contentEl.blur();
      this.game.display.contentEl.setAttribute("aria-hidden", true);
      this.game.display.contentEl.setAttribute("inert", "");
      // activate dialog
      this.hint_dialog.style.display = "block";
      this.hint_dialog.classList.add("active");
      this.hint_dialog.setAttribute("aria-hidden", false);
      this.hint_dialog.focus();
    }

    /**
     * In the case of mixed hints / hint groups, we'll have placed a reveal
     * button in an awkward place and we want to reposition it.
     * @memberOf adventurejs.HintManager
     * @method adventurejs.HintManager#placeButtons
     * @kind function
     */
    placeButtons() {
      const containers = this.hint_list.querySelectorAll(".ajs-nested-hints");

      containers.forEach((container) => {
        // snapshot of direct children and their original indices
        const children = Array.from(container.children);
        const indexOf = new Map(children.map((n, i) => [n, i]));

        // find the last direct child that is an .ajs-hinteractive
        let lastInteractiveIndex = -1;
        for (let i = children.length - 1; i >= 0; i--) {
          if (children[i].classList.contains("ajs-hinteractive")) {
            lastInteractiveIndex = i;
            break;
          }
        }

        // nothing to do if there are no interactive items
        if (lastInteractiveIndex === -1) return;

        // find all direct .ajs-hint-group children in original order
        const groups = children.filter((n) =>
          n.classList.contains("ajs-hint-group")
        );

        // move groups that were originally before the last interactive
        // we insert each moved group *after* the current lastInteractiveIndex,
        // then bump lastInteractiveIndex so subsequent moves append after the previous one.
        groups.forEach((group) => {
          const origIndex = indexOf.get(group);
          if (origIndex < lastInteractiveIndex) {
            // where to insert: after the current lastInteractiveIndex
            const insertBeforeNode =
              container.children[lastInteractiveIndex + 1] || null;
            container.insertBefore(group, insertBeforeNode);
            lastInteractiveIndex += 1; // moved group now sits after the interactive(s)
          }
        });
      });
    }

    /**
     * Reveal a hint.
     * @method adventurejs.HintManager#revealHint
     */
    revealHint(event) {
      const button = event.currentTarget;
      const group = button.closest(".ajs-hint-group");
      const nest = button.closest(".ajs-nested-hints");
      const reveal = nest.querySelector(":scope > .ajs-hint:not(.revealed)");
      if (reveal === null) {
        button.classList.add("hidden");
        return;
      }

      const hintnode = this.game.hintcard.findHintByKey(reveal.dataset.key);
      hintnode.revealed = true;
      reveal.classList.add("revealed");

      this.game.hintcard.countHints();
      this.updateCounts();
      const count = nest.querySelectorAll(":scope > .revealed").length;
      const next = count + 1;
      if (next > button.dataset.total) {
        button.classList.add("hidden");
      } else {
        button.querySelector(".next").innerHTML = next;
      }
      // const groupnode = this.game.hintcard.findHintByKey(group.dataset.key);
      // if (groupnode.unrevealed === 0) {
      //   button.classList.add("hidden");
      // }
    }

    /**
     * Provides a chainable shortcut method for setting a number of properties on the instance.
     * @method adventurejs.HintManager#set
     * @param {Object} props A generic object containing properties to copy to the DisplayObject instance.
     * @returns {adventurejs.HintManager} Returns the instance the method is called on (useful for chaining calls.)
     * @chainable
     */
    set(props) {
      return A.deepSet.call(this.game, props, this);
    }

    /**
     * Update hint counts.
     * @method adventurejs.HintManager#updateCounts
     */
    updateCounts() {
      const groups = this.hint_dialog.querySelectorAll(
        ".ajs-hint-group[data-unrevealed]"
      );
      groups.forEach((group) => {
        const unrevealed = this.game.hintcard.findHintByKey(
          group.dataset.key
        ).unrevealed;
        group.dataset.unrevealed = unrevealed;
        const groupEl = group.querySelector(".ajs-hint-unrevealed");
        groupEl.innerHTML = unrevealed;
        groupEl.dataset.unrevealed = unrevealed;
      });
    }
  }
  adventurejs.HintManager = HintManager;
})();