flbulgarelli/headbreaker

View on GitHub
src/puzzle.js

Summary

Maintainability
C
7 hrs
Test Coverage
const {Anchor} = require('./anchor');
const Piece = require('./piece');
const {NullValidator} = require('./validator');
const {vector, ...Vector} = require('./vector')
const {radius} = require('./size')
const Shuffler = require('./shuffler');
const dragMode = require('./drag-mode');
const {Connector, noConnectionRequirements} = require('./connector');

/**
 * A puzzle primitive representation that can be easily stringified, exchanged and persisted
 *
 * @typedef {object} PuzzleDump
 * @property {import('./vector').Vector} pieceRadius
 * @property {number} proximity
 * @property {import('./piece').PieceDump[]} pieces
 */

/**
 * @typedef {object} Settings
 * @property {import('./vector').Vector|number} [pieceRadius]
 * @property {number} [proximity]
 */


 /**
  * A set of a {@link Piece}s that can be manipulated as a whole, and that can be
  * used as a pieces factory
  */
class Puzzle {

  /**
   * @param {Settings} [options]
   */
  constructor({pieceRadius = 2, proximity = 1} = {}) {
    this.pieceSize = radius(pieceRadius);
    this.proximity = proximity;
    /** @type {Piece[]} */
    this.pieces = [];
    /** @type {import('./validator').Validator} */
    this.validator = new NullValidator();
    /** @type {import('./drag-mode').DragMode} */
    this.dragMode = dragMode.TryDisconnection;

    this.horizontalConnector = Connector.horizontal();
    this.verticalConnector = Connector.vertical();
  }

  /**
   * Creates and adds to this puzzle a new piece
   *
   * @param {import('./structure').Structure} [structure] the piece structure
   * @param {import('./piece').PieceConfig} [config] the piece config
   * @returns {Piece} the new piece
   */
  newPiece(structure = {}, config = {}) {
    const piece = new Piece(structure, config);
    this.addPiece(piece);
    return piece;
  }

  /**
   * @param {Piece} piece
   */
  addPiece(piece) {
    this.pieces.push(piece);
    piece.belongTo(this);
  }

  /**
   * @param {Piece[]} pieces
   */
  addPieces(pieces) {
    pieces.forEach(it => this.addPiece(it));
  }

  /**
   * Annotates all the pieces with the given list of metadata
   *
   * @param {object[]} metadata
   */
  annotate(metadata) {
    this.pieces.forEach((piece, index) => piece.annotate(metadata[index]));
  }

   /**
   * Relocates all the pieces to the given list of points
   *
   * @param {import('./pair').Pair[]} points
   */
  relocateTo(points) {
    this.pieces.forEach((piece, index) => piece.relocateTo(...points[index]));
  }

  /**
   * Tries to connect pieces in their current positions
   * This method is O(n^2)
   */
  autoconnect() {
    this.pieces.forEach(it => this.autoconnectWith(it));
  }

  /**
   * Disconnects all pieces
   */
  disconnect() {
    this.pieces.forEach(it => it.disconnect());
  }

  /**
   * Tries to connect the given piece to the rest of the set
   * This method is O(n)
   * @param {Piece} piece
   */
  autoconnectWith(piece) {
    this.pieces.filter(it => it !== piece).forEach(other => {
      piece.tryConnectWith(other);
      other.tryConnectWith(piece, true);
    })
  }

  /**
   * @param {number} maxX
   * @param {number} maxY
   */
  shuffle(maxX, maxY) {
    this.shuffleWith(Shuffler.random(maxX, maxY));
  }

  /**
   * @param {import('./shuffler').Shuffler} shuffler
   */
  shuffleWith(shuffler) {
    this.disconnect();
    shuffler(this.pieces).forEach(({x, y}, index) => {
      this.pieces[index].relocateTo(x, y);
    });
    this.autoconnect();
  }

  /**
   * @param {number} dx
   * @param {number} dy
   */
  translate(dx, dy) {
    this.pieces.forEach(it => it.translate(dx, dy));
  }

  /**
   * Translates all the puzzle pieces so that are completely
   * within the given bounds, if possible.
   *
   * If pieces can not be completly places within the given
   * bounding box, the the `max` param is ignored.
   *
   * @param {import('./vector').Vector} min
   * @param {import('./vector').Vector} max
   */
  reframe(min, max) {
    let dx;
    const leftOffstage = min.x - Math.min(...this.pieces.map(it => it.leftAnchor.x));
    if (leftOffstage > 0) {
      dx = leftOffstage;
    } else {
      const rightOffstage = max.x - Math.max(...this.pieces.map(it => it.rightAnchor.x))
      if (rightOffstage < 0) {
        dx = rightOffstage;
      } else {
        dx = 0;
      }
    }

    let dy;
    const upOffstage = min.y - Math.min(...this.pieces.map(it => it.upAnchor.y));
    if (upOffstage > 0) {
      dy = upOffstage;
    } else {
      const downOffstage = max.y - Math.max(...this.pieces.map(it => it.downAnchor.y))
      if (downOffstage < 0) {
        dy = downOffstage;
      } else {
        dy = 0;
      }
    }

    this.translate(dx, dy);
  }

  /**
   * @param {import('./piece').TranslationListener} f
   */
  onTranslate(f) {
    this.pieces.forEach(it => it.onTranslate(f));
  }

  /**
   * @param {import('./piece').ConnectionListener} f
   */
  onConnect(f) {
    this.pieces.forEach(it => it.onConnect(f));
  }

  /**
   * @param {import('./piece').ConnectionListener} f
   */
  onDisconnect(f) {
    this.pieces.forEach(it => it.onDisconnect(f));
  }

  /**
   * @param {import('./validator').ValidationListener} f
   */
  onValid(f) {
    this.validator.onValid(f);
  }

  /**
   * Answers the list of points where
   * central anchors of pieces are located
   *
   * @type {import('./pair').Pair[]}
   */
  get points() {
    return this.pieces.map(it => it.centralAnchor.asPair());
  }

  /**
   * Answers a list of points whose coordinates are scaled
   * to the {@link Puzzle#pieceWidth}
   *
   * @type {import('./pair').Pair[]}
   */
  get refs() {
    return this.points.map(([x, y], index) => {
      const diameter = this.pieces[index].diameter;
      return [x / diameter.x, y / diameter.y]
    })
  }

  /**
   * @type {any[]}
   */
  get metadata() {
    return this.pieces.map(it => it.metadata);
  }

  /**
   * Returns the first piece
   *
   * @type {Piece}
   */
  get head() {
    return this.pieces[0];
  }

  /**
   * Returns the central anchor of the first piece
   *
   * @type {Anchor}
   */
  get headAnchor() {
    return this.head.centralAnchor;
  }

  /**
   * Returns the attached vertical ConnectionRequirement
   * function.
   *
   * @returns {import('./connector').ConnectionRequirement}
   */
  get verticalRequirement() {
    return this.verticalConnector.requirement;
  }

  /**
   * Returns the attached horizontal ConnectionRequirement
   * function.
   *
   * @returns {import('./connector').ConnectionRequirement}
   */
  get horizontalRequirement() {
    return this.horizontalConnector.requirement;
  }

  /**
   * Attaches a connection requirement function that will be used to check whether
   * two horizontally close and matching pieces can be actually connected.
   *
   * By default no horizontal connection requirement is imposed which means that any horizontally
   * close and matching pieces will be connected.
   *
   * @param {import('./connector').ConnectionRequirement} requirement
   */
  attachHorizontalConnectionRequirement(requirement) {
    this.horizontalConnector.attachRequirement(requirement);
  }

  /**
   * Attaches a connection requirement function that will be used to check whether
   * two vertically close and matching pieces can be actually connected.
   *
   * By default no vertical connection requirement is imposed which means that any vertically
   * close and matching pieces will be connected.
   *
   * @param {import('./connector').ConnectionRequirement} requirement
   */
  attachVerticalConnectionRequirement(requirement) {
    this.verticalConnector.attachRequirement(requirement);
  }

  /**
   * Attaches the given connection requirement as both a vertical and horizontal requirement.
   *
   * @see {@link Puzzle#attachVerticalConnectionRequirement}
   * @see {@link Puzzle#attachHorizontalConnectionRequirement}
   *
   * @param {import('./connector').ConnectionRequirement} requirement
   */
  attachConnectionRequirement(requirement) {
    this.attachHorizontalConnectionRequirement(requirement);
    this.attachVerticalConnectionRequirement(requirement);
  }

  /**
   * Removes the vertical and horizontal connection requirements, if any.
   */
  clearConnectionRequirements() {
    this.attachConnectionRequirement(noConnectionRequirements)
  }

  /**
   * @param {import('./validator').Validator} validator
   */
  attachValidator(validator) {
    this.validator = validator;
  }

  /**
   * Checks whether this puzzle is valid.
   *
   * Calling this method will not fire any validation listeners nor update the
   * valid property.
   *
   * @returns {boolean}
   */
  isValid() {
    return this.validator.isValid(this);
  }

  /**
   * Returns the current validation status
   *
   * Calling this property will not fire any validation listeners.
   *
   * @type {boolean}
   */
  get valid() {
    return this.validator.valid;
  }

  /**
   * Checks whether this puzzle is valid, updating valid property
   * and firing validation listeners if becomes valid
   */
  validate() {
    this.validator.validate(this);
  }

  /**
   * Checks whether this puzzle is valid, updating valid property.
   *
   * Validations listeners are NOT fired.
   */
  updateValidity() {
    this.validator.validate(this);
  }

  /**
   * Wether all the pieces in this puzzle are connected
   *
   * @type {boolean}
   */
  get connected() {
    return this.pieces.every(it => it.connected);
  }

  /**
   * The piece width, from edge to edge.
   * This is the double of the {@link Puzzle#pieceRadius}
   *
   * @type {import('./vector').Vector}
   */
  get pieceDiameter() {
    return this.pieceSize.diameter;
  }

  /**
   * The piece width, from center to edge
   *
   * @type {import('./vector').Vector}
   */
  get pieceRadius() {
    return this.pieceSize.radius;
  }

  /** Prevents pieces from disconnecting */
  forceConnectionWhileDragging() {
    this.dragMode = dragMode.ForceConnection;
  }

  /** Forces pieces to disconnect */
  forceDisconnectionWhileDragging() {
    this.dragMode = dragMode.ForceDisconnection;
  }

  /** Forces pieces to disconnect */
  tryDisconnectionWhileDragging() {
    this.dragMode = dragMode.TryDisconnection;
  }

  /**
   * @param {Piece} piece
   * @param {number} dx
   * @param {number} dy
   * @see {@link Piece#dragShouldDisconnect}
   */
  dragShouldDisconnect(piece, dx, dy) {
    return this.dragMode.dragShouldDisconnect(piece, dx, dy);
  }

  /**
   * Converts this piece into a plain, stringify-ready object.
   * Pieces should have ids
   *
   * @param {object} options config options for export
   * @param {boolean} [options.compact] if connection information must be omitted
   * @returns {PuzzleDump}
   */
  export(options = {}) {
    return {
      pieceRadius: this.pieceRadius,
      proximity: this.proximity,
      pieces: this.pieces.map(it => it.export(options))
    }
  }

  /**
   * @param {PuzzleDump} dump
   * @returns {Puzzle}
   */
  static import(dump) {
    const puzzle = new Puzzle({pieceRadius: dump.pieceRadius, proximity: dump.proximity});
    puzzle.addPieces(dump.pieces.map(it => Piece.import(it)));
    puzzle.autoconnect();
    return puzzle;
  }
}

module.exports = Puzzle;