lib/public/js/muzzle.js
/**
* @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;