Source: Frontend/game/common/game-map/game-map.mjs

/* eslint-disable complexity,no-mixed-operators,consistent-return */
/** @module common/game-map/GameMap */
import {MIN_SIZE, THEMES} from "./game-map-const.mjs";
import Room from "./room.mjs";
import Corridor from "./corridor.mjs";
import Ladder from "./../ladder.mjs";

/**
 * Map a 2d coordinate to a 1d coordinate
 * @private
 * @param {*} x
 * @param {*} y
 */
const d21 = (x, y, size) => {
  return y * size + x;
};

/**
 * Map a 1d coordinate to a 2d coordinate
 * @private
 * @param {*} x
 * @param {*} y
 */
const d12 = (i, size) => {
  return [i % size, Math.floor(i / size)];
};

/**
 * A map for a game
 * @prop {string} id Unique id for this map
 * @prop {string} theme The theme for this map
 * @prop {Room[]} rooms The rooms in the map
 * @prop {Item[]} items The items in this map
 * @prop {Player[]} players The players in this map
 */
export default class GameMap {
  /**
   * <span style="color: red;">Constructor is private.</span>
   * See GameMap.parse or GameMap.generate for instances of GameMap
   */
  constructor() {
    this.rooms = [];
    this.ladder = new Ladder();
  }

  /**
   * Initialize the map generation parameters
   * @private
   * @param params
   */
  _initParams(params = {}, numItems = 0) {
    this._params = {
      nodes: params.nodes || 16,
      minRoom: (params.minRoom || 20) * MIN_SIZE,
      maxRoom: (params.maxRoom || 60) * MIN_SIZE,
      maxYDist: (params.maxYDist || 12) * MIN_SIZE,
      roomChance: params.roomChange || 0.9,
      corridorSize: (params.corridorSize || 8) * MIN_SIZE,
      xPadding: (params.xPadding || 4) * MIN_SIZE,
      yPadding: (params.xPadding || 4) * MIN_SIZE,
      theme: params.theme || THEMES[Math.floor(Math.random() * THEMES.length)],
      spawn: params.spawn
    };

    this.numItems = numItems;

    if(!this._params.spawn) {
      this._params.spawn = Math.floor(Math.random() * this._params.nodes);
    }

    if(this._params.spawn >= this._params.nodes || this._params.spawn < 0) {
      throw new Error("Spawn must be less that nodes and greater than or equal to 0");
    }

    this._params.size = Math.sqrt(this._params.nodes);
    if(this._params.size !== Math.floor(this._params.size)) {
      throw new Error("nodes must be a perfect square");
    }
  }

  /**
   * Geneate a game map
   * @param {GameMap} map The game map to store everything in
   * @param {object} params The game map generation parameters for the game map
   * @returns {GameMap}
   */
  static generate(map, params) {
    map._initParams(params, map.numItems);

    map.generateMap(map.generateMaze());
    return map;
  }

  /**
   * A room and corridor renderer
   * @typedef Renderer
   * @prop {string} name The name of the renderer
   * @prop {Function} canRender Can this render the room or corridor that is passed in
   * @prop {Function} render Render the actual room or corridor
   */

  /**
   * Regester a renderer for rooms and corridors
   * @param renderer {Renderer} The renderer to use
   */
  static register(renderer) {
    GameMap._renderers.set(renderer.name, renderer);
  }

  /**
   * Check if a point is on the map/floor
   * @param {number} x
   * @param {number} y
   * @param {boolean} isMonster
   * @returns {boolean}
   */
  isOnMap(x, y, isMonster) {
    let rect = this.getRect(x, y);
    if(!rect) {
      return;
    }

    return !isMonster || !rect.noMonsters;
  }

  /**
   * Get the room of corridor that contains the x, y corrdinate otherwise return undefined
   * @param {number} x
   * @param {number} y
   * @returns {Room|Corridor}
   */
  getRect(x, y) {
    const check = (rect) => {
      return rect.x < x && x < rect.x + rect.width &&
        rect.y < y && y < rect.y + rect.height;
    };

    for(let room of this.rooms) {
      if(check(room)) {
        return room;
      } else if(room.left && check(room.left)) {
        return room.left;
      } else if(room.above && check(room.above)) {
        return room.above;
      }
    }
  }

  /**
   * Serialize the game map
   * @returns {string} The serialized map
   */
  serialize() {
    return JSON.stringify({
      rooms: this.rooms,
      ladder: this.ladder,
      numItems: this.numItems,
      params: {
        nodes: this._params.nodes,
        minRoom: this._params.minRoom / MIN_SIZE,
        maxRoom: this._params.maxRoom / MIN_SIZE,
        maxYDist: this._params.maxYDist / MIN_SIZE,
        roomChance: this._params.roomChange,
        corridorSize: this._params.corridorSize / MIN_SIZE,
        xPadding: this._params.xPadding / MIN_SIZE,
        yPadding: this._params.xPadding / MIN_SIZE,
        theme: this._params.theme || "0-0",
        spawn: this._params.spawn,
      }
    });
  }

  /**
   * Parse a previously serialized map
   * @param {string|object} json The serialized map
   * @param {GameMap} map The game map to use
   * @returns {GameMap}
   */
  static parse(json, map) {
    let raw = typeof json === "string" ? JSON.parse(json) : json;

    map._initParams(raw.params, raw.numItems);

    for(let i = 0; i < raw.rooms.length; ++i) {
      map.rooms.push(Room._parse(i, map.rooms, raw.rooms[i], map._params));
    }

    map.ladder = raw.ladder;

    return map;
  }

  /**
   * The basic maze generation algorithm.
   * @private
   * @returns A map of edges
   */
  generateMaze() {
    let unvisited = new Set();
    let corridors = new Map();
    let stack = [];

    for(let i = 0; i < this._params.nodes; ++i) {
      unvisited.add(i);
    }

    let startingPoint = Math.floor(Math.random() * unvisited.size);
    stack.push(startingPoint);

    while(stack.length) {
      let current = stack[stack.length - 1];
      let [x, y] = d12(current, this._params.size);

      // find all unvisited neighbours
      let unvisitedNeighbours = [];

      if(unvisited.has(d21(x + 1, y, this._params.size)) && x + 1 < this._params.size) {
        unvisitedNeighbours.push(d21(x + 1, y, this._params.size));
      }

      if(unvisited.has(d21(x, y + 1, this._params.size)) && y + 1 < this._params.size) {
        unvisitedNeighbours.push(d21(x, y + 1, this._params.size));
      }

      if(unvisited.has(d21(x - 1, y, this._params.size)) && x - 1 >= 0) {
        unvisitedNeighbours.push(d21(x - 1, y, this._params.size));
      }

      if(unvisited.has(d21(x, y - 1, this._params.size)) && y - 1 >= 0) {
        unvisitedNeighbours.push(d21(x, y - 1, this._params.size));
      }

      // all of our neighbours have been visited go back to the previous node
      if(!unvisitedNeighbours.length) {
        stack.pop();
        continue;
      }

      // visit a neighbouring cell
      let toCell = unvisitedNeighbours[Math.floor(Math.random() * unvisitedNeighbours.length)];
      unvisited.delete(toCell);

      // create the edges for the corridors
      if(!corridors.has(toCell)) {
        corridors.set(toCell, new Set());
      }

      if(!corridors.has(current)) {
        corridors.set(current, new Set());
      }

      corridors.get(toCell).add(current);
      corridors.get(current).add(toCell);

      stack.push(toCell);
    }

    return corridors;
  }

  /**
   * Expand the maze generated by generateMaze into something with differently sized rooms
   * @private
   * @param {*} corridors
   * @returns {Object} rooms
   */
  generateMap(corridors) {
    let map = this;
    // Coords for the room we are placing
    let x = this._params.xPadding;
    let y = this._params.yPadding;
    // Height of the tallest room in this row
    let maxHeight = 0;
    // Location width and heights of all rooms
    map.rooms = [];
    let renderers = Array.from(GameMap._renderers.values());

    // Place rooms onto the map
    for(let i = 0; i < this._params.nodes; ++i) {
      // We are at the end of this row start a new row
      if(i % this._params.size === 0 && i > 0) {
        x = this._params.xPadding;
        y += maxHeight + Math.floor(Math.random() * this._params.maxYDist) + this._params.yPadding;
        maxHeight = 0;
      }

      let width = this._params.corridorSize;
      let height = this._params.corridorSize;

      // determine if this box should be a room
      if(Math.random() < this._params.roomChance || corridors.get(i).size === 1) {
        width = Math.floor(Math.random() * (this._params.maxRoom - this._params.minRoom)) + this._params.minRoom;
        height = Math.floor(Math.random() * (this._params.maxRoom - this._params.minRoom)) + this._params.minRoom;
      }

      x += Math.max(this._params.maxRoom - width, 0);

      // save for corridor rendering
      let newRoom = new Room(i, x, y, width, height, this._params);
      map.rooms.push(newRoom);

      // pick a renderer
      let renderer;
      do {
        renderer = renderers[Math.floor(Math.random() * renderers.length)];
      } while(!renderer.canRender(newRoom));

      newRoom._rendererName = renderer.name;

      maxHeight = Math.max(height, maxHeight);

      // Render a corridor to the box to our left (if there is one)
      if(corridors.get(i).has(i - 1)) {
        let room = map.rooms[i - 1];

        // find a row that we have in common
        let yStart = Math.max(y, room.y);
        let sharedHeight = Math.min(height - (yStart - y), room.height - (yStart - room.y)) - this._params.corridorSize;
        let yPos = Math.floor(Math.random() * sharedHeight) + yStart;

        // Add weights to the graph
        let edge = new Corridor(
          room.x + room.width, yPos, x - (room.x + room.width), this._params.corridorSize, this._params);

        // pick a corridor renderer
        do {
          renderer = renderers[Math.floor(Math.random() * renderers.length)];
        } while(!renderer.canRender(edge));

        edge._rendererName = renderer.name;

        room._connect(map.rooms[i], edge);
      }

      // Render a corridor to the box above us
      if(corridors.get(i).has(i - this._params.size)) {
        let room = map.rooms[i - this._params.size];

        // find a column that we have in common
        let xStart = Math.max(x, room.x);
        let sharedWidth = Math.min(width - (xStart - x), room.width - (xStart - room.x)) - this._params.corridorSize;
        let xPos = Math.floor(Math.random() * sharedWidth) + xStart;

        // Add weights to the graph
        let edge = new Corridor(
          xPos, room.y + room.height, this._params.corridorSize, y - (room.y + room.height), this._params);

        // pick a corridor renderer
        do {
          renderer = renderers[Math.floor(Math.random() * renderers.length)];
        } while(!renderer.canRender(edge));

        edge._rendererName = renderer.name;

        room._connect(map.rooms[i], edge);
      }

      x += width + this._params.xPadding;
    }
  }

  get theme() {
    return this._params.theme;
  }

  /**
   * Get the spawn point for a player
   * @returns {object} {x,y}
   */
  getSpawnPoint() {
    let room = this.rooms[this._params.spawn];

    let x = room.x + Math.floor(Math.random() * (room.width * 3 / 4)) + Math.floor(room.width / 4);
    let y = room.y + Math.floor(Math.random() * (room.height * 3 / 4)) + Math.floor(room.height / 4);

    if(!this.isOnMap(x, y)) {
      throw new Error("Spawn point not on map");
    }

    return {x, y};
  }
}

GameMap._renderers = new Map();