mumuki/mumuki-puzzle-runner

View on GitHub
lib/public/js/muzzle.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @typedef {number[]} Point
 */

/**
 * @typedef {object} Solution
 * @property {Point[]} positions list of points
 */


class MuzzlePainter extends headbreaker.painters.Konva {
  _newLine(options) {

    const line = super._newLine(options);
    line.strokeScaleEnabled(false);
    return line;
  }
}

/**
 * Facade for referencing and creating a global puzzle canvas,
 * handling solutions persistence and submitting them
 */
class MuzzleCanvas {

  // =============
  // Global canvas
  // =============

  constructor(id = 'muzzle-canvas') {

    /**
     * @private
     * @type {Canvas}
     **/
    this._canvas = null;

    /**
     * The id of the HTML element that will contain the canvas
     * Override it you are going to place in a non-standard way
     *
     * @type {string}
     */
    this.canvasId = id;

    /**
     * An optional list of refs that, if set, will be used to validate
     * this puzzle both on client and server side
     *
     * @private
     * @type {Point[]}
     * */
    this._expectedRefs = null;

    /**
     * Wether expected refs shall be ignored by Muzzle.
     *
     * They will still be evaluated server-side.
     *
     * @type {boolean}
     */
    this.expectedRefsAreOnlyDescriptive = false;

    /**
     * Width of canvas
     *
     * @type {number}
     */
    this.canvasWidth = 600;

    /**
     * Height of canvas
     *
     * @type {number}
     */
    this.canvasHeight = 600;

    /**
     * Wether canvas shoud **not** be resized.
     * Default is `false`
     *
     * @type {boolean}
     */
    this.fixedDimensions = false;

    /**
     * Size of fill. Set null for perfect-match
     *
     * @type {number}
     */
    this.borderFill = null;

    /**
     * Canvas line width
     *
     * @type {number}
     */
    this.strokeWidth = 3;

    /**
     * Piece size
     *
     * @type {number}
     */
    this.pieceSize = 100;

    /**
     * The `x:y` aspect ratio of the piece. Set null for automatic
     * aspectRatio
     *
     * @type {number}
     */
    this.aspectRatio = null;

    /**
     * If the images should be adjusted vertically instead of horizontally
     * to puzzle dimensions.
     *
     * Set null for automatic fit.
     *
     * @type {boolean}
     */
    this.fitImagesVertically = null;

    /**
     * Wether the scaling should ignore the scaler
     * rise events
     */
    this.manualScale = false;

    /**
     * The canvas shuffler.
     *
     * Set it null to automatic shuffling algorithm selection.
     */
    this.shuffler = null;

    /**
     * Callback that will be executed
     * when muzzle has fully loaded and rendered its first
     * canvas.
     *
     * It does nothing by default but you can override this
     * property with any code you need the be called here
     */
    this.onReady = () => {};

    /**
     * The previous solution to the current puzzle in a past session,
     * if any
     *
     * @type {string}
     */
    this.previousSolutionContent = null;

    /**
     * Whether the current puzzle can be solved in very few tries.
     *
     * Set null for automatic configuration of this property. Basic puzzles will be considered
     * basic and match puzzles will be considered non-simple.
     *
     * @type {boolean}
     */
    this.simple = null;

    this.spiky = false;

    /**
     * The reference insert axis, used at rounded outline to compute insert internal and external diameters
     *
     * Set null for default computation of axis - no axis reference for basic boards
     * and vertical axis for match
     *
     * @type {Axis}
     * */
    this.referenceInsertAxis = null;

    /**
     * Callback to be executed when submitting puzzle.
     *
     * Does nothing by default but you can
     * override it to perform additional actions
     *
     * @param {{solution: {content: string}, client_result: {status: "passed" | "failed"}}} submission
     */
    this.onSubmit = (submission) => {};

     /**
     * Callback that will be executed
     * when muzzle's puzzle becomes valid
     *
     * It does nothing by default but you can override this
     * property with any code you need the be called here
     */
    this.onValid = () => {};

    /**
     * @private
     */
    this._ready = false;
  }

  get painter() {
    return new MuzzlePainter();
  }

  /**
   */
  get baseConfig() {
    return Object.assign({
      preventOffstageDrag: true,
      width: this.canvasWidth,
      height: this.canvasHeight,
      pieceSize: this.adjustedPieceSize,
      proximity: Math.min(this.adjustedPieceSize.x, this.adjustedPieceSize.y) / 5,
      strokeWidth: this.strokeWidth,
      lineSoftness: 0.18,
      painter: this.painter
    }, this.outlineConfig);
  }

  /**
   */
  get outlineConfig() {
    if (this.spiky) {
      return {
        borderFill: this.borderFill === null ? headbreaker.Vector.divide(this.adjustedPieceSize, 10) : this.borderFill,
      }
    } else {
      return {
        borderFill: 0,
        outline: new headbreaker.outline.Rounded({
          bezelize: true,
          insertDepth: 3/5,
          bezelDepth: 9/10,
          referenceInsertAxis: this.referenceInsertAxis
        }),
      }
    }
  }

  /**
   * The piece size, adjusted to the aspect ratio
   *
   * @returns {Vector}
   */
  get adjustedPieceSize() {
    if (!this._adjustedPieceSize) {
      const aspectRatio = this.effectiveAspectRatio;
      this._adjustedPieceSize = headbreaker.vector(this.pieceSize * aspectRatio, this.pieceSize);
    }
    return this._adjustedPieceSize;
  }

  /**
   * @type {Axis}
   */
  get imageAdjustmentAxis() {
    return this.fitImagesVertically ? headbreaker.Vertical : headbreaker.Horizontal;
  }

  /**
   * The configured aspect ratio, or 1
   *
   * @type {number}
   */
  get effectiveAspectRatio() {
    return this.aspectRatio || 1;
  }

  /**
   * The currently active canvas, or null if
   * it has not yet initialized
   *
   * @returns {Canvas}
   */
  get canvas() {
    return this._canvas;
  }

  /**
   * Draws the - previusly built - current canvas.
   *
   * Prefer `this.currentCanvas.redraw()` when performing
   * small updates to the pieces.
   */
  draw() {
    this.canvas.draw();
  }

  // ========
  // Building
  // ========

  /**
   * @param {Point[]} refs
   */
  expect(refs) {
    this._expectedRefs = refs;
  }

  /**
   * Creates a basic puzzle canvas with a rectangular shape
   * and a background image, that is automatically
   * submitted when solved
   *
   * @param {number} x the number of horizontal pieces
   * @param {number} y the number of vertical pieces
   * @param {string} imagePath
   * @returns {Promise<Canvas>} the promise of the built canvas
   */
  async basic(x, y, imagePath) {
    this._config('aspectRatio', y / x);
    this._config('simple', true);
    this._config('shuffler', Muzzle.Shuffler.grid);

    /**
     * @todo take all container size
     **/
    const image = await this._loadImage(imagePath);
    /** @type {Canvas} */
    // @ts-ignore
    const canvas = this._createCanvas({ image: image });
    canvas.adjustImagesToPuzzle(this.imageAdjustmentAxis);
    canvas.autogenerate({ horizontalPiecesCount: x, verticalPiecesCount: y });
    this._attachBasicValidator(canvas);
    this._configCanvas(canvas);
    canvas.onValid(() => {
      setTimeout(() => {
        if (canvas.valid) {
          this.submit();
        }
      }, 1500);
    });
    return canvas;
  }

  /**
   * Creates a choose puzzle, where a single right piece must match the single left piece,
   * choosing the latter from a bunch of other left odd pieces. By default, `Muzzle.Shuffler.line` shuffling is used.
   *
   * This is a particular case of a match puzzle with line
   *
   * @param {string} leftUrl the url of the left piece
   * @param {string} rightUrl the url of the right piece
   * @param {string[]} leftOddUrls the urls of the off left urls
   * @param {number} [rightAspectRatio] the `x:y` ratio of the right pieces, that override the general `aspectRatio` of the puzzle.
   *                                    Use null to have the same aspect ratio as left pieces
   *
   * @returns {Promise<Canvas>} the promise of the built canvas
   */
  async choose(leftUrl, rightUrl, leftOddUrls, rightAspectRatio = null) {
    this._config('shuffler', Muzzle.Shuffler.line);
    return this.match([leftUrl], [rightUrl], {leftOddUrls, rightAspectRatio});
  }

  /**
   * Creates a match puzzle, where left pieces are matched against right pieces,
   * with optional odd left and right pieces that don't match. By default, `Muzzle.Shuffler.columns`
   * shuffling is used.
   *
   * @param {string[]} leftUrls
   * @param {string[]} rightUrls must be of the same size of lefts
   * @param {object} [options]
   * @param {string[]} [options.leftOddUrls]
   * @param {string[]} [options.rightOddUrls]
   * @param {number?} [options.rightAspectRatio] the aspect ratio of the right pieces. Use null to have the same aspect ratio as left pieces
   * @returns {Promise<Canvas>} the promise of the built canvas
   */
  async match(leftUrls, rightUrls, {leftOddUrls = [], rightOddUrls = [], rightAspectRatio = this.effectiveAspectRatio} = {}) {
    const rightWidthRatio = rightAspectRatio / this.effectiveAspectRatio;

    this._config('simple', false);
    this._config('shuffler', Muzzle.Shuffler.columns);
    this._config('fitImagesVertically', rightWidthRatio > 1);
    this._config('referenceInsertAxis', headbreaker.Vertical);

    /** @private @type {(Promise<Template>)[]} */
    const templatePromises = [];

    const rightSize = headbreaker.diameter(
      headbreaker.Vector.multiply(this.adjustedPieceSize, headbreaker.vector(rightWidthRatio, 1)));

    const pushTemplate = (path, options) =>
      templatePromises.push(this._createMatchTemplate(path, options));

    const pushLeftTemplate = (index, path, options) =>
      pushTemplate(path, {
        left: true,
        targetPosition: headbreaker.Vector.multiply(this.pieceSize, headbreaker.vector(1, index)),
        ...options
      });

    const pushRightTemplate = (index, path, options) =>
      pushTemplate(path, {
        size: rightSize,
        targetPosition: headbreaker.Vector.multiply(this.pieceSize, headbreaker.vector(2, index)),
        ...options
      });

    const last = leftUrls.length - 1;
    for (let i = 0; i <= last; i++) {
      const leftId = `l${i}`;
      const rightId = `r${i}`;

      pushLeftTemplate(i + 1, leftUrls[i], {
        id: leftId,
        rightTargetId: rightId
      });
      pushRightTemplate(i + 1, rightUrls[i], {
        id: rightId
      });
    }

    leftOddUrls.forEach((it, i) =>
      pushLeftTemplate(i + leftUrls.length, it, {
        id: `lo${i}`,
        odd: true
      })
    );
    rightOddUrls.forEach((it, i) =>
      pushRightTemplate(i + rightUrls.length, it, {
        id: `ro${i}`,
        odd: true
      })
    );

    //  + Math.max(leftOddUrls.length, rightOddUrls.length)
    const templates = await Promise.all(templatePromises);
    /** @type {Canvas} */
    const canvas = this._createCanvas({ maxPiecesCount: {x: 2, y: leftUrls.length} });
    canvas.adjustImagesToPiece(this.imageAdjustmentAxis);
    templates.forEach(it => canvas.sketchPiece(it));
    this._attachMatchValidator(canvas);
    this._configCanvas(canvas);
    return canvas;
  }

  /**
   * @param {Canvas} canvas
   * @returns {Promise<Canvas>} the promise of the built canvas
   */
  custom(canvas) {
    this._configCanvas(canvas);
    return Promise.resolve(canvas);
  }

  /**
   * @private
   * @param {any} config
   * @return {Canvas}
   */
  _createCanvas(config = {}) {
    return new headbreaker.Canvas(this.canvasId, Object.assign(config, this.baseConfig));
  }

  /**
   * @private
   * @param {Canvas} canvas
   */
  _attachBasicValidator(canvas) {
    if (!this.expectedRefsAreOnlyDescriptive && this._expectedRefs) {
      canvas.attachRelativeRefsValidator(this._expectedRefs);
    } else {
      canvas.attachSolvedValidator();
    }
  }

  /**
   * @private
   * @param {Canvas} canvas
   */
  _attachMatchValidator(canvas) {
    canvas.attachValidator(new headbreaker.PuzzleValidator(
      puzzle => puzzle.pieces
                  .filter(it => !it.metadata.odd && it.metadata.left)
                  .every(it => it.rightConnection && it.rightConnection.id === it.metadata.rightTargetId)
    ));
  }

  /**
   * @private
   * @param {string} path
   * @returns {Promise<HTMLImageElement>}
   */
  _loadImage(path) {
    const image = new Image();
    image.src = path;
    return new Promise((resolve, reject) => image.onload = () => resolve(image));
  }

  /**
   * @private
   * @param {string} imagePath
   * @param {object} options
   * @returns {Promise<object>}
   */
  _createMatchTemplate(imagePath, {id, left = false, targetPosition = null, rightTargetId = null, odd = false, size = null}) {
    const structure = left ? 'T-N-' : `N-S-`;
    return this._loadImage(imagePath).then((image) => {
      return {
        ...(size ? {size} : {}),
        structure,
        metadata: { id, left, odd, rightTargetId, image, targetPosition }
      }
    });
  }

  /**
   * @private
   * @param {Canvas} canvas
   */
  _configCanvas(canvas) {
    this._canvas = canvas;
    this._canvas.shuffleWith(0.8, this.shuffler);
    this._canvas.onValid(() => {
      setTimeout(() => this.onValid(), 0);
    });
    this._setUpScaler();
    this.ready();
  }

  _setUpScaler() {
    if (this.manualScale) return;

    ['resize', 'load'].forEach((event) => {
      window.addEventListener(event, () => {
        console.debug("Scaler event fired:", event);
        var container = document.getElementById(this.canvasId);
        this.scale(container.offsetWidth, container.scrollHeight);
      });
    });
  }

  /**
   * Scales the canvas to the given width and height
   *
   * @param {number} width
   * @param {number} height
   */
  scale(width, height) {
    if (this.fixedDimensions || !this.canvas) return;

    console.debug("Scaling:", {width, height})
    const factor = this.optimalScaleFactor(width, height);
    this.canvas.resize(width, height);
    this.canvas.scale(factor);
    this.canvas.redraw();
    this.focus();
  }

  /**
   * Focuses the stage around the canvas center
   */
  focus() {
    const stage = this.canvas['__konvaLayer__'].getStage();

    const area = headbreaker.Vector.divide(headbreaker.vector(stage.width(), stage.height()), stage.scaleX());
    const realDiameter = (() => {
      const [xs, ys] = this.coordinates;

      const minX = Math.min(...xs);
      const minY = Math.min(...ys);

      const maxX = Math.max(...xs);
      const maxY = Math.max(...ys);

      return headbreaker.vector(maxX - minX, maxY - minY);
    })();
    const diff = headbreaker.Vector.minus(area, realDiameter);
    const semi = headbreaker.Vector.divide(diff, -2);

    stage.setOffset(semi);
    stage.draw();
  }

  /**
   * @private
   */
  get coordinates() {
    const points = this.canvas.puzzle.points;
    return [points.map(([x, _y]) => x), points.map(([_x, y]) => y)];
  }

  /**
   * @private
   * @param {number} width
   * @param {number} height
   */
  optimalScaleFactor(width, height) {
    const factors = headbreaker.Vector.divide(headbreaker.vector(width, height), this.canvas.puzzleDiameter);
    return Math.min(factors.x, factors.y) / 1.75;
  }

  /**
   * Mark Muzzle as ready, loading previous solution
   * and drawing the canvas
   */
  ready() {
    this.loadPreviousSolution();
    this.resetCoordinates();
    this.draw();
    this._ready = true;
    this.onReady();
  }

  isReady() {
    return this._ready;
  }

  // ===========
  // Persistence
  // ===========

  /**
   * The state of the current puzzle
   * expressed as a Solution object
   *
   * @returns {Solution}
   */
  get solution() {
    return { positions: this.canvas.puzzle.points }
  }

  /**
   * Loads - but does not draw - a solution into the canvas.
   *
   * @param {Solution} solution
   */
  loadSolution(solution) {
    this.canvas.puzzle.relocateTo(solution.positions);
    this.canvas.puzzle.autoconnect();
  }

  /**
   * Loads - but does not draw - the current canvas with the previous solution, if available.
   *
   */
  loadPreviousSolution() {
    if (this.previousSolutionContent) {
      try {
        this.loadSolution(JSON.parse(this.previousSolutionContent));
      } catch (e) {
        console.warn("Ignoring unparseabe editor value");
      }
    }
  }

  /**
   * Translates the pieces so that
   * they start at canvas' coordinates origin
   */
  resetCoordinates() {
    const [xs, ys] = this.coordinates;
    const minX = Math.min(...xs);
    const minY = Math.min(...ys);
    this.canvas.puzzle.translate(-minX, -minY);
  }

  // ==========
  // Submitting
  // ==========

  /**
   * Submits the puzzle to the bridge,
   * validating it if necessary
   */
  submit() {
    this.onSubmit(this._prepareSubmission());
  }

  /**
   * The current solution, expressed as a JSON string
   */
  get solutionContent() {
    return JSON.stringify(this.solution);
  }

  /**
   * The solution validation status
   *
   * @returns {"passed" | "failed"}
   */
  get clientResultStatus() {
    return this.canvas.valid ? 'passed' : 'failed';
  }

  _prepareSubmission() {
    return {
      solution: {
        content: this.solutionContent
      },
      client_result: {
        status: this.clientResultStatus
      }
    };
  }

  /**
   * @param {string} key
   * @param {any} value
   */
  _config(key, value) {
    const current = this[key];
    console.debug("Setting config: ", [key, value])

    if (current === null) {
      this[key] = value;
    }
  }

  // ==============
  // Event handling
  // ==============


  /**
   * Registers an event handler
   *
   * @param {string} event
   * @param {(...args: any) => void} callback
   */
  register(event, callback) {
    const _event = this[event];
    this[event] = (...args) => {
      callback(...args);
      _event(...args);
    }
  }

  /**
   * Runs the given action if muzzle is ready,
   * queueing it otherwise
   * @param {() => void} callback
   */
  run(callback) {
    if (this.isReady()) {
      callback();
    } else {
      this.register('onReady', callback);
    }
  }
}

const Muzzle = new class extends MuzzleCanvas {
  constructor() {
    super();
    this.aux = {};

    this.Shuffler = headbreaker.Shuffler;
  }

  /**
   * Creates a suplementary canvas at the element
   * of the given id
   *
   * @param {string} id
   * @returns {MuzzleCanvas}
   */
  another(id) {
    const muzzle = new MuzzleCanvas(id);
    Muzzle.aux[id] = muzzle
    return muzzle;
  }
}

window['Muzzle'] = Muzzle;