From ff1f64585529d7a81ba00469e20518f062dd97cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sat, 14 Dec 2019 19:05:42 +0100 Subject: [PATCH 1/7] split orb placement logic to separate class to allow pluggable implementations --- script.js | 1074 +++++++++++++++++++++++++++++------------------------ 1 file changed, 579 insertions(+), 495 deletions(-) diff --git a/script.js b/script.js index 3d7e1f8..269f97c 100644 --- a/script.js +++ b/script.js @@ -262,6 +262,15 @@ class Board { this.removeOrbByIndex(index) } + /** + * Count orbs in the game board + * + * @return {number} + */ + countOrbs() { + return this.grid.reduce((acu, x) => acu + (x !== null), 0); + } + /** * Remove orb by array index * @@ -642,42 +651,117 @@ class Board { return this.grid[n]; } + /** + * Check if a cell is available for selection in-game + * (checking surroundings only). This should work the same regardless + * of whether the cell is occupied or not. + * + * @param n + * @return {boolean} + */ + isAvailable(n) { + return this.getCellInfo(n).freeSequence >= 3; + } + + /** + * Get info about a cell surroundings and position + * + * neighbours - number of occupied neighbouring cells + * centerWeight - distance from the board center + * freeSequence - number of contiguous free tiles in the direct neighbourhood + * + * @param n + * @return {{neighbours: number, centerWeight: number, freeSequence: number}} + */ + getCellInfo(n) { + let {x, y} = gridIndexToXy(n); + + let freeSpaces = [ + isXyOutside(x - 1, y) || !this.grid[n - 1], + isXyOutside(x - 1, y - 1) || !this.grid[n - 12], + isXyOutside(x, y - 1) || !this.grid[n - 11], + isXyOutside(x + 1, y) || !this.grid[n + 1], + isXyOutside(x + 1, y + 1) || !this.grid[n + 12], + isXyOutside(x, y + 1) || !this.grid[n + 11], + ]; + + let nOccupied = 0; + for (let i = 0; i < 6; i++) { + if (!freeSpaces[i]) { + nOccupied++; + } + } + + let freeSequence = 0; + let maxFreeSequence = 0; + for (let i = 0; i < 12; i++) { + if (freeSpaces[i % 6]) { + freeSequence++; + if (freeSequence >= 6) { + maxFreeSequence = freeSequence; + break; + } + if (freeSequence > maxFreeSequence) { + maxFreeSequence = freeSequence; + } + } else { + freeSequence = 0; + } + } + + let { rx, ry } = this.gridXyToCoord(x, y); + + return { + neighbours: nOccupied, + freeSequence: maxFreeSequence, + centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))), + }; + } + + /** + * Build the game GUI (buttons, links) + */ buildGui() { + const addButton = (x, y, text, classes='') => { + let { rx, ry } = this.guiXyToCoord(x, y); + let button = Svg.fromXML(`${text}`); + this.$root.appendChild(button); + return button; + }; + const y0 = 0.05; const x0 = 0; const ysp = 0.75; const ysp2 = 0.6; - this.buttons.randomize = this.addButton(x0, y0, 'Randomize'); - this.buttons.restart = this.addButton(x0, y0 + ysp, 'Try Again', 'disabled'); - this.buttons.undo = this.addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); + this.buttons.randomize = addButton(x0, y0, 'Randomize'); + this.buttons.restart = addButton(x0, y0 + ysp, 'Try Again', 'disabled'); + this.buttons.undo = addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); const cfgy0 = 10; - this.buttons.optFancy = this.addButton(x0, cfgy0, 'Effects:', 'config'); - this.buttons.optBlockedEffect = this.addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); - this.buttons.optSloppy = this.addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); - this.buttons.toggleFullscreen = this.addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config'); + this.buttons.optFancy = addButton(x0, cfgy0, 'Effects:', 'config'); + this.buttons.optBlockedEffect = addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); + this.buttons.optSloppy = addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); + this.buttons.toggleFullscreen = addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config'); - this.buttons.btnAbout = this.addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config'); + this.buttons.btnAbout = addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config'); let youWin = Svg.fromXML(`Good work!`); this.$root.appendChild(youWin); this.youWin = youWin; } + /** + * Update toggles + * + * @param cfg + */ updateSettingsGUI(cfg) { this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off'); this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? 'On' : 'Off'); this.buttons.optSloppy.textContent = 'Sloppy Mode: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off'); } - - addButton(x, y, text, classes='') { - let { rx, ry } = this.guiXyToCoord(x, y); - let button = Svg.fromXML(`${text}`); - this.$root.appendChild(button); - return button; - } } /** @@ -733,6 +817,33 @@ class Rng { nextInt(max) { return Math.floor((max + 1) * this.next()); } + + /** + * Shuffle an array. + * The array is shuffled in place. + * + * @return the array + */ + arrayShuffle(a) { + let j, x, i; + for (i = a.length - 1; i > 0; i--) { + j = this.nextInt(i); + x = a[i]; + a[i] = a[j]; + a[j] = x; + } + return a; + } + + /** + * Choose a random member of an array + * + * @param array + * @return {number} + */ + arrayChoose(array) { + return array[Math.floor(this.next() * array.length)]; + } } class SettingsStorage { @@ -748,6 +859,7 @@ class SettingsStorage { svgEffects: false, dimBlocked: true, logSolution: false, + highlightTemplate: false, }; this.settings = Object.assign({}, this.defaults); } @@ -1101,501 +1213,152 @@ class Game { }; } - isAvailable(n) { - return this.getNeighbours(n).freeSequence >= 3; + placeOrbs(template) { + let placer = new RadialOrbPlacer(this, template); + return placer.place(); } - getNeighbours(n) { - let {x, y} = gridIndexToXy(n); + getPairSymbols(first) { + return { + 'salt': ['salt', 'air', 'fire', 'water', 'earth'], + 'air': ['salt', 'air'], + 'fire': ['salt', 'fire'], + 'water': ['salt', 'water'], + 'earth': ['salt', 'earth'], + 'mercury': [this.nextMetal], + 'lead': ['mercury'], + 'tin': ['mercury'], + 'iron': ['mercury'], + 'copper': ['mercury'], + 'silver': ['mercury'], + 'gold': [], + 'mors': ['vitae'], + 'vitae': ['mors'], + }[first]; + } - let freeSpaces = [ - isXyOutside(x - 1, y) || !this.board.grid[n - 1], - isXyOutside(x - 1, y - 1) || !this.board.grid[n - 12], - isXyOutside(x, y - 1) || !this.board.grid[n - 11], - isXyOutside(x + 1, y) || !this.board.grid[n + 1], - isXyOutside(x + 1, y + 1) || !this.board.grid[n + 12], - isXyOutside(x, y + 1) || !this.board.grid[n + 11], - ]; + advanceMetal() { + if (this.nextMetal === 'gold') throw new Error("No metals to unlock beyond gold."); + this.nextMetal = METAL_SEQ[METAL_SEQ.indexOf(this.nextMetal) + 1]; + console.debug(`Next metal unlocked: ${this.nextMetal}`); + } - let nOccupied = 0; - for (let i = 0; i < 6; i++) { - if (!freeSpaces[i]) { - nOccupied++; + /** + * Convert the strings in the board array to actual SVG orbs (strings are a placeholder to speed up board solving) + */ + renderPreparedBoard() { + for (let n = 0; n < BOARD_SIZE; n++) { + if (this.board.grid[n] !== null) { + const symbol = this.board.grid[n]; + this.board.grid[n] = null; + this.board.placeOrbByIndex(n, symbol); } } + } - let freeSequence = 0; - let maxFreeSequence = 0; - for (let i = 0; i < 12; i++) { - if (freeSpaces[i % 6]) { - freeSequence++; - if (freeSequence >= 6) { - maxFreeSequence = freeSequence; - break; - } - if (freeSequence > maxFreeSequence) { - maxFreeSequence = freeSequence; - } - } else { - freeSequence = 0; + /** + * Check if a tile is available at play-time (checking unlocked metals) + * + * @param n + * @return {Boolean} + */ + isAvailableAtPlaytime(n) { + let ava = this.board.isAvailable(n); + + const sym = this.board.grid[n].symbol; + if (METAL_SEQ.includes(sym)) { + if (sym !== this.nextMetal) { + ava = false; } } - let { rx, ry } = this.board.gridXyToCoord(x, y); + return ava; + } - return { - neighbours: nOccupied, - freeSequence: maxFreeSequence, - centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))), - }; + addUndoRecord(orbs) { + this.undoStack.push({ + nextMetal: this.nextMetal, + orbs, + }) } - placeOrbs(template) { - let tilesAdded = 0; + undo() { + if (!this.undoStack.length) { + console.warn("Undo stack is empty."); + return; + } - this.board.removeAllOrbs(); + let item = this.undoStack.pop(); - let allowedTable = []; - let outsideTemplate = []; - for (let i = 0; i < BOARD_SIZE; i++) { - const allo = template.includes(i); - allowedTable.push(allo); + this.nextMetal = item.nextMetal; + for (let entry of item.orbs) { + this.debug(`Undo orb ${entry.symbol} at ${entry.n}`); + this.board.placeOrbByIndex(entry.n, entry.symbol); + } - let {x, y} = gridIndexToXy(i); - if (!allo && !isXyOutside(x, y)) { - outsideTemplate.push(i); - } + this.updateGameGui(); + } - // Highlight pattern shape + /** + * Handle orb click + * + * @param n + * @param orb + */ + inGameBoardClick(n, orb) { + let removed = false; + this.debug(`Clicked orb ${n}: ${orb.symbol}`); - // if (this.board.tiles[i]) { - // if (allo) { - // this.board.tiles[i].setAttribute('opacity', 1) - // } else { - // this.board.tiles[i].setAttribute('opacity', 0.6) - // } - // } + if (!this.isAvailableAtPlaytime(n)) { + this.debug(`Orb is blocked`); + return; } - const place = (n, symbol) => { - this.trace(`Place ${n} <- ${symbol}`); - if (!allowedTable[n]) throw Error(`Position ${n} not allowed by template`); - if (this.board.grid[n]) throw Error(`Position ${n} is occupied by ${this.board.grid[n]}`); - // we use a hack to speed up generation here - SVG is not altered until we have a solution - this.board.grid[n] = symbol; - }; - - const unplace = (n) => { - this.trace(`Unplace ${n}`); - this.board.grid[n] = null; - }; + let wantRefresh = false; - const findBestCandidate = (except = null) => { - let candidates = []; - for (let n of template) { - if (except && except.includes(n)) continue; - - if (!this.board.grid[n]) { - // is free - const neigh = this.getNeighbours(n); - if (neigh.freeSequence >= 3) { - candidates.push({ - n, - cw: neigh.centerWeight - }); - } - } - } + if (orb.symbol === 'gold') { + // gold has no pairing + this.debug(`Removing gold.`); - candidates.sort((a, b) => a.cw - b.cw); + this.addUndoRecord([{ + symbol: orb.symbol, + n, + }]); - if (candidates.length) { - // return candidates[0].n; - let top = []; - let topw = candidates[0].cw; - for(let cand of candidates) { - if (cand.cw <= topw + this.cfg.difficulty) { // TODO this could vary by template - top.push(cand); - } - } + this.board.removeOrbByIndex(n); + this.selectedOrb = null; + removed = true; + wantRefresh = true; + } else if (this.selectedOrb === null) { + this.debug(`Select orb`); + // first selection + this.selectedOrb = {n, orb}; + orb.node.classList.add('selected'); + } else { + if (this.selectedOrb.n === n) { + this.debug(`Unselect orb`); + // orb clicked twice + orb.node.classList.remove('selected'); + this.selectedOrb = null; + } else { + this.debug(`Second selection, try to match`); - //the neighbor count is not used for anything anymore, oops + // second orb in a pair + const otherSymbol = this.selectedOrb.orb.symbol; - // console.log('Got a choice of '+top.length+' tiles'); + if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) { + this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`); - return this.arrayChoose(top).n; - } - - return false; - }; - - const findAvailableIndex = (except = null) => { - const n = findBestCandidate(except); - if (n !== false) return n; - - // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. - if (template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { - // Prefer tile with more neighbours to make the game harder - let candidates = []; - outsideTemplate.forEach((n) => { - if (!allowedTable[n] && this.isAvailable(n)) { - const neigh = this.getNeighbours(n); - if (!candidates[neigh.neighbours]) { - candidates[neigh.neighbours] = [n]; - } else { - candidates[neigh.neighbours].push(n); - } - } - }); - - for (let i = 6; i >= 0; i--) { - if (!candidates[i]) continue; - let toAdd = this.arrayChoose(candidates[i]); - allowedTable[toAdd] = true; - outsideTemplate.splice(outsideTemplate.indexOf(toAdd), 1); - - template.push(toAdd); - tilesAdded++; - - this.warn(`Adding extra tile to template: ${toAdd}`); - return toAdd; - } - } - - throw Error("Failed to find available board tile."); - }; - - place(60, 'gold'); - - const toPlace = this.buildPlacementList(); - - let solution = [ - ['gold', 60], - ]; - while (toPlace.length > 0) { - this.trace('placing a pair.'); - - let symbol1 = toPlace.pop(); - let index1 = findAvailableIndex(); - place(index1, symbol1); - solution.push([symbol1, index1]); - - let symbol2 = toPlace.pop(); - let index2 = findAvailableIndex(); - place(index2, symbol2); - - if (!this.isAvailable(index1)) { - this.debug(`Deadlock, trying to work around it - ${index1}, ${index2}`); - - unplace(index2); - let except = [index2]; - - let suc = false; - for (let i = 0; i < 5; i++) { - this.trace(`try #${i + 1}`); - let index = findAvailableIndex(except); - place(index, symbol2); - - if (this.isAvailable(index1)) { - suc = true; - index2 = index; - break; - } else { - unplace(index); - except.push(index); - } - } - - if (!suc) { - throw new Error("Solution contains a deadlock."); - } - } - - // index2 is updated in the fixing loop - solution.push([symbol2, index2]); - } - - // Show the solution for debug - - solution.reverse(); - this.info("Found a valid board!"); - - solution.forEach((a) => { - let p = gridIndexToXy(a[1]); - a[1] = `${p.x}×${p.y}`; - }); - - this.debug('Solution:', solution); - - return { - solution, - tilesAdded: tilesAdded, - }; - } - - /** - * Shuffle an array. - * The array is shuffled in place. - * - * @return the array - */ - arrayShuffle(a) { - let j, x, i; - for (i = a.length - 1; i > 0; i--) { - j = this.rng.nextInt(i); - x = a[i]; - a[i] = a[j]; - a[j] = x; - } - return a; - } - - /** - * Count orbs in the game board - * - * @return {number} - */ - countOrbs() { - return this.board.grid.reduce((acu, x) => acu + (x !== null), 0); - } - - /** - * Choose a random emember of an array - * - * @param array - * @return {number} - */ - arrayChoose(array) { - return array[Math.floor(this.rng.next() * array.length)]; - } - - buildPlacementList() { - let toPlace = [ - ['air', 'air'], - ['air', 'air'], - ['air', 'air'], - ['air', 'air'], - - ['fire', 'fire'], - ['fire', 'fire'], - ['fire', 'fire'], - ['fire', 'fire'], - - ['water', 'water'], - ['water', 'water'], - ['water', 'water'], - ['water', 'water'], - - ['earth', 'earth'], - ['earth', 'earth'], - ['earth', 'earth'], - ['earth', 'earth'], - ]; - - let newSaltedPairs = []; - const nsalted = this.rng.nextInt(2); - for (let i = 0; i < nsalted; i++) { - while (true) { - const n = this.rng.nextInt(toPlace.length - 1); - if (toPlace[n][1] !== 'salt') { - this.trace(`Pairing ${toPlace[n][1]} with salt.`); - newSaltedPairs.push([toPlace[n][1], 'salt']); - toPlace[n][1] = 'salt'; - break; - } - } - } - toPlace = toPlace.concat(newSaltedPairs); - // if we have some salt pairs left - for (let i = 0; i < 2 - nsalted; i++) { - toPlace.push(['salt', 'salt']); - } - - // these are always paired like this, and don't support salt - toPlace = toPlace.concat([ - ['mors', 'vitae'], - ['mors', 'vitae'], - ['mors', 'vitae'], - ['mors', 'vitae'], - ]); - - // shuffle the pairs that have random order (i.e. not metals) - this.arrayShuffle(toPlace); - - // the order here is actually significant, so let's pay attention... - const metals = [ - ['lead', 'mercury'], - ['tin', 'mercury'], - ['iron', 'mercury'], - ['copper', 'mercury'], - ['silver', 'mercury'], - ]; - let mPos = []; - for (let i = 0; i < metals.length; i++) { - let x; - // find a unique position - do { - x = this.rng.nextInt(toPlace.length + i); - } while (mPos.includes(x)); - mPos.push(x) - } - mPos.sort((a, b) => a - b); - this.trace('Metal positions ', mPos); - // inject them into the array - metals.forEach((pair, i) => { - toPlace.splice(mPos[i] + i, 0, pair); - }); - - this.debug('Placement order (last first):', toPlace); - - return toPlace.reduce((a, c) => { - a.push(c[0]); - a.push(c[1]); - return a; - }, []); - } - - getPairSymbols(first) { - return { - 'salt': ['salt', 'air', 'fire', 'water', 'earth'], - 'air': ['salt', 'air'], - 'fire': ['salt', 'fire'], - 'water': ['salt', 'water'], - 'earth': ['salt', 'earth'], - 'mercury': [this.nextMetal], - 'lead': ['mercury'], - 'tin': ['mercury'], - 'iron': ['mercury'], - 'copper': ['mercury'], - 'silver': ['mercury'], - 'gold': [], - 'mors': ['vitae'], - 'vitae': ['mors'], - }[first]; - } - - advanceMetal() { - if (this.nextMetal === 'gold') throw new Error("No metals to unlock beyond gold."); - this.nextMetal = METAL_SEQ[METAL_SEQ.indexOf(this.nextMetal) + 1]; - console.debug(`Next metal unlocked: ${this.nextMetal}`); - } - - /** - * Convert the strings in the board array to actual SVG orbs (strings are a placeholder to speed up board solving) - */ - renderPreparedBoard() { - for (let n = 0; n < BOARD_SIZE; n++) { - if (this.board.grid[n] !== null) { - const symbol = this.board.grid[n]; - this.board.grid[n] = null; - this.board.placeOrbByIndex(n, symbol); - } - } - } - - /** - * Check if a tile is available at play-time (checking unlocked metals) - * - * @param n - * @return {Boolean} - */ - isAvailableAtPlaytime(n) { - let ava = this.isAvailable(n); - - const sym = this.board.grid[n].symbol; - if (METAL_SEQ.includes(sym)) { - if (sym !== this.nextMetal) { - ava = false; - } - } - - return ava; - } - - addUndoRecord(orbs) { - this.undoStack.push({ - nextMetal: this.nextMetal, - orbs, - }) - } - - undo() { - if (!this.undoStack.length) { - console.warn("Undo stack is empty."); - return; - } - - let item = this.undoStack.pop(); - - this.nextMetal = item.nextMetal; - for (let entry of item.orbs) { - this.debug(`Undo orb ${entry.symbol} at ${entry.n}`); - this.board.placeOrbByIndex(entry.n, entry.symbol); - } - - this.updateGameGui(); - } - - /** - * Handle orb click - * - * @param n - * @param orb - */ - inGameBoardClick(n, orb) { - let removed = false; - this.debug(`Clicked orb ${n}: ${orb.symbol}`); - - if (!this.isAvailableAtPlaytime(n)) { - this.debug(`Orb is blocked`); - return; - } - - let wantRefresh = false; - - if (orb.symbol === 'gold') { - // gold has no pairing - this.debug(`Removing gold.`); - - this.addUndoRecord([{ - symbol: orb.symbol, - n, - }]); - - this.board.removeOrbByIndex(n); - this.selectedOrb = null; - removed = true; - wantRefresh = true; - } else if (this.selectedOrb === null) { - this.debug(`Select orb`); - // first selection - this.selectedOrb = {n, orb}; - orb.node.classList.add('selected'); - } else { - if (this.selectedOrb.n === n) { - this.debug(`Unselect orb`); - // orb clicked twice - orb.node.classList.remove('selected'); - this.selectedOrb = null; - } else { - this.debug(`Second selection, try to match`); - - // second orb in a pair - const otherSymbol = this.selectedOrb.orb.symbol; - - if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) { - this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`); - - this.addUndoRecord([ - { - symbol: this.selectedOrb.orb.symbol, - n: this.selectedOrb.n, - }, - { - symbol: orb.symbol, - n, - } - ]); + this.addUndoRecord([ + { + symbol: this.selectedOrb.orb.symbol, + n: this.selectedOrb.n, + }, + { + symbol: orb.symbol, + n, + } + ]); // compatible pair clicked this.board.removeOrbByIndex(n); @@ -1623,7 +1386,7 @@ class Game { } if (wantRefresh) { - if (this.countOrbs() === 0) { + if (this.board.countOrbs() === 0) { this.info("Good work!"); if (removed) { @@ -1700,7 +1463,7 @@ class Game { * Update button hiding attributes, disabled orb effects, etc */ updateGameGui() { - let nOrbs = this.countOrbs(); + let nOrbs = this.board.countOrbs(); this.board.buttons.restart .classList.toggle('disabled', nOrbs === 55); @@ -1773,11 +1536,7 @@ class Game { break; } catch (e) { retry_count++; - if (this.logging_trace) { - this.error(e); - } else { - this.warn(e.message); - } + this.warn(e.message); } } @@ -1792,10 +1551,7 @@ class Game { if (!suc) { alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`); } else { - this.info( - `Board set up with ${retry_count} retries:\n` + - `teplate "${board_info.template.name}"` + - (this.cfg.allowTemplateAugmenting ? ` with ${board_info.tilesAdded} extra tiles` : '')); + this.info(`Board set up with ${retry_count} retries.`); if (this.cfg.logSolution) { this.info('Reference solution:\n ' + board_info.solution.reduce((s, entry, i) => { @@ -1816,6 +1572,334 @@ class Game { } } +class BaseOrbPlacer { + constructor(game, template) { + this.template = template; + this.rng = game.rng; + this.cfg = game.cfg; + this.game = game; + this.board = game.board; + } + + placeOrb(n, symbol) { + if (!this.templateMap[n]) { + throw Error(`Position ${n} not allowed by template`); + } + if (this.board.grid[n]) { + throw Error(`Position ${n} is occupied by ${this.board.grid[n]}`); + } + this.trace(`Place ${n} <- ${symbol}`); + this.board.grid[n] = symbol; + } + + removeOrb(n) { + let old = this.board.grid[n]; + this.board.grid[n] = null; + this.trace(`Unplace ${n} (${old})`); + return old; + } + + isAvailable(n) { + return this.board.isAvailable(n); + } + + getCellInfo(n) { + return this.board.getCellInfo(n); + } + + trace(...args) { + this.game.debug(...args); + } + + debug(...args) { + this.game.debug(...args); + } + + info(...args) { + this.game.info(...args); + } + + warn(...args) { + this.game.warn(...args); + } + + error(...args) { + this.game.error(...args); + } + + buildPlacementList() { + let toPlace = [ + ['air', 'air'], + ['air', 'air'], + ['air', 'air'], + ['air', 'air'], + + ['fire', 'fire'], + ['fire', 'fire'], + ['fire', 'fire'], + ['fire', 'fire'], + + ['water', 'water'], + ['water', 'water'], + ['water', 'water'], + ['water', 'water'], + + ['earth', 'earth'], + ['earth', 'earth'], + ['earth', 'earth'], + ['earth', 'earth'], + ]; + + let newSaltedPairs = []; + const nsalted = this.rng.nextInt(2); + for (let i = 0; i < nsalted; i++) { + while (true) { + const n = this.rng.nextInt(toPlace.length - 1); + if (toPlace[n][1] !== 'salt') { + this.trace(`Pairing ${toPlace[n][1]} with salt.`); + newSaltedPairs.push([toPlace[n][1], 'salt']); + toPlace[n][1] = 'salt'; + break; + } + } + } + toPlace = toPlace.concat(newSaltedPairs); + // if we have some salt pairs left + for (let i = 0; i < 2 - nsalted; i++) { + toPlace.push(['salt', 'salt']); + } + + // these are always paired like this, and don't support salt + toPlace = toPlace.concat([ + ['mors', 'vitae'], + ['mors', 'vitae'], + ['mors', 'vitae'], + ['mors', 'vitae'], + ]); + + // shuffle the pairs that have random order (i.e. not metals) + this.rng.arrayShuffle(toPlace); + + // the order here is actually significant, so let's pay attention... + const metals = [ + ['lead', 'mercury'], + ['tin', 'mercury'], + ['iron', 'mercury'], + ['copper', 'mercury'], + ['silver', 'mercury'], + ]; + let mPos = []; + for (let i = 0; i < metals.length; i++) { + let x; + // find a unique position + do { + x = this.rng.nextInt(toPlace.length + i); + } while (mPos.includes(x)); + mPos.push(x) + } + mPos.sort((a, b) => a - b); + this.trace('Metal positions ', mPos); + // inject them into the array + metals.forEach((pair, i) => { + toPlace.splice(mPos[i] + i, 0, pair); + }); + + this.debug('Placement order (last first):', toPlace); + + return toPlace.reduce((a, c) => { + a.push(c[0]); + a.push(c[1]); + return a; + }, []); + } + + + place() { + this.board.removeAllOrbs(); + this.solution = []; + + let allowedTable = []; + let outsideTemplate = []; + for (let i = 0; i < BOARD_SIZE; i++) { + const allo = this.template.includes(i); + allowedTable.push(allo); + + let {x, y} = gridIndexToXy(i); + if (!allo && !isXyOutside(x, y)) { + outsideTemplate.push(i); + } + + // Highlight pattern shape + + if (this.cfg.highlightTemplate) { + if (this.board.tiles[i]) { + if (allo) { + this.board.tiles[i].setAttribute('opacity', 1) + } else { + this.board.tiles[i].setAttribute('opacity', 0.6) + } + } + } + } + this.templateMap = allowedTable; + this.outsideTemplate = outsideTemplate; + + this.placeOrb(60, 'gold'); + this.solution.push(['gold', 60]); + + let rv = this.doPlace(); + + let solution = this.solution; + + solution.reverse(); + this.info("Found a valid board!"); + + solution.forEach((a) => { + let p = gridIndexToXy(a[1]); + a[1] = `${p.x}×${p.y}`; + }); + + this.debug('Solution:', solution); + + rv.solution = solution; + return rv; + } + + doPlace() { + throw new Error("Not implemented"); + } +} + +class RadialOrbPlacer extends BaseOrbPlacer { + findBestCandidate(except = null) { + let candidates = []; + for (let n of this.template) { + if (except && except.includes(n)) continue; + + if (!this.board.grid[n]) { + // is free + const neigh = this.getCellInfo(n); + if (neigh.freeSequence >= 3) { + candidates.push({ + n, + cw: neigh.centerWeight + }); + } + } + } + + candidates.sort((a, b) => a.cw - b.cw); + + if (candidates.length) { + // return candidates[0].n; + let top = []; + let topw = candidates[0].cw; + for(let cand of candidates) { + if (cand.cw <= topw + this.cfg.difficulty) { // TODO this could vary by template + top.push(cand); + } + } + + //the neighbor count is not used for anything anymore, oops + + // console.log('Got a choice of '+top.length+' tiles'); + + return this.rng.arrayChoose(top).n; + } + + return false; + } + + findAvailableIndex(except = null) { + const n = this.findBestCandidate(except); + if (n !== false) return n; + + // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. + if (this.template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { + // Prefer tile with more neighbours to make the game harder + let candidates = []; + this.outsideTemplate.forEach((n) => { + if (!this.templateMap[n] && this.isAvailable(n)) { + const neigh = this.getCellInfo(n); + if (!candidates[neigh.neighbours]) { + candidates[neigh.neighbours] = [n]; + } else { + candidates[neigh.neighbours].push(n); + } + } + }); + + for (let i = 6; i >= 0; i--) { + if (!candidates[i]) continue; + let toAdd = this.rng.arrayChoose(candidates[i]); + this.templateMap[toAdd] = true; + this.outsideTemplate.splice(this.outsideTemplate.indexOf(toAdd), 1); + + this.template.push(toAdd); + this.tilesAdded++; + + this.warn(`Adding extra tile to template: ${toAdd}`); + return toAdd; + } + } + + throw Error("Failed to find available board tile."); + } + + doPlace() { + this.tilesAdded = 0; + const toPlace = this.buildPlacementList(); + + while (toPlace.length > 0) { + this.trace('placing a pair.'); + + let symbol1 = toPlace.pop(); + let index1 = this.findAvailableIndex(); + this.placeOrb(index1, symbol1); + this.solution.push([symbol1, index1]); + + let symbol2 = toPlace.pop(); + let index2 = this.findAvailableIndex(); + this.placeOrb(index2, symbol2); + + if (!this.isAvailable(index1)) { + this.debug(`Deadlock, trying to work around it - ${index1}, ${index2}`); + + this.removeOrb(index2); + let except = [index2]; + + let suc = false; + for (let i = 0; i < 5; i++) { + this.trace(`try #${i + 1}`); + let index = this.findAvailableIndex(except); + this.placeOrb(index, symbol2); + + if (this.isAvailable(index1)) { + suc = true; + index2 = index; + break; + } else { + this.removeOrb(index); + except.push(index); + } + } + + if (!suc) { + throw new Error("Solution contains a deadlock."); + } + } + + // index2 is updated in the fixing loop + this.solution.push([symbol2, index2]); + } + + return { + tilesAdded: this.tilesAdded, + }; + } +} + + /* Start */ window.game = new Game(); From 4e4ce7c9622f6e4bce3312f0fa6785e17f4d6188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sat, 14 Dec 2019 19:20:09 +0100 Subject: [PATCH 2/7] add some comments --- script.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/script.js b/script.js index 269f97c..810ac73 100644 --- a/script.js +++ b/script.js @@ -1572,6 +1572,9 @@ class Game { } } +/** + * Base class for orb placer. + */ class BaseOrbPlacer { constructor(game, template) { this.template = template; @@ -1581,6 +1584,12 @@ class BaseOrbPlacer { this.board = game.board; } + /** + * Place an orb. The position must be inside template and free. + * + * @param n - position + * @param symbol - symbol to place + */ placeOrb(n, symbol) { if (!this.templateMap[n]) { throw Error(`Position ${n} not allowed by template`); @@ -1592,6 +1601,11 @@ class BaseOrbPlacer { this.board.grid[n] = symbol; } + /** + * Remove an orb, if any. + * + * @param n - position + */ removeOrb(n) { let old = this.board.grid[n]; this.board.grid[n] = null; @@ -1599,10 +1613,12 @@ class BaseOrbPlacer { return old; } + /** Check if a cell is available for selection - 3 free slots */ isAvailable(n) { return this.board.isAvailable(n); } + /** Get cell info by number */ getCellInfo(n) { return this.board.getCellInfo(n); } @@ -1627,6 +1643,13 @@ class BaseOrbPlacer { this.game.error(...args); } + /** + * Build a list of orbs in the right order. + * They are always grouped in matching pairs, and metals are always in + * the correct reaction order. + * + * @return {string[]} + */ buildPlacementList() { let toPlace = [ ['air', 'air'], @@ -1713,7 +1736,9 @@ class BaseOrbPlacer { }, []); } - + /** + * Run the placement logic. + */ place() { this.board.removeAllOrbs(); this.solution = []; @@ -1747,7 +1772,8 @@ class BaseOrbPlacer { this.placeOrb(60, 'gold'); this.solution.push(['gold', 60]); - let rv = this.doPlace(); + let toPlace = this.buildPlacementList(); + let rv = this.doPlace(toPlace); let solution = this.solution; @@ -1765,12 +1791,42 @@ class BaseOrbPlacer { return rv; } - doPlace() { + /** + * Perform the orbs placement. + * + * Orb symbols to place are given as an argument. + * + * The layout template is in `this.template`, also `this.templateMap` as an array + * with indices 0-121 containing true/false to indicate template membership. + * + * `this.outsideTemplate` is a list of cell indices that are not in the template. + * + * this.solution is an array to populate with orb placements in the format [n, symbol]. + * + * The board is cleared now. When the function ends, the board should contain + * the new layout (as symbol name strings) + * + * After placing each orb, make sure to call `this.solution.push([symbol, index])`; + * in case of backtracking, pop it again. + * + * Return object to return to parent; 'solution' will be added automatically. + */ + doPlace(toPlace) { throw new Error("Not implemented"); } } +/** + * Orb placement algorithm that starts in the center and places orbs in rings, with some + * small jitter allowed. + */ class RadialOrbPlacer extends BaseOrbPlacer { + /** + * Find a candidate cell + * + * @param {number[]|null} except - indices to exclude + * @return {number} + */ findBestCandidate(except = null) { let candidates = []; for (let n of this.template) { @@ -1810,6 +1866,12 @@ class RadialOrbPlacer extends BaseOrbPlacer { return false; } + /** + * Find index for next placement + * + * @param {number[]|null} except - indices to exclude + * @return {number} - index + */ findAvailableIndex(except = null) { const n = this.findBestCandidate(except); if (n !== false) return n; @@ -1846,9 +1908,8 @@ class RadialOrbPlacer extends BaseOrbPlacer { throw Error("Failed to find available board tile."); } - doPlace() { + doPlace(toPlace) { this.tilesAdded = 0; - const toPlace = this.buildPlacementList(); while (toPlace.length > 0) { this.trace('placing a pair.'); From 081f252e897d41b2e310312c47d1083e514d6d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 15 Dec 2019 00:14:45 +0100 Subject: [PATCH 3/7] new recursive backtracking builder --- script.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/script.js b/script.js index 810ac73..ea8297c 100644 --- a/script.js +++ b/script.js @@ -1214,7 +1214,8 @@ class Game { } placeOrbs(template) { - let placer = new RadialOrbPlacer(this, template); + // let placer = new RadialOrbPlacer(this, template); + let placer = new RecursiveOrbPlacer(this, template); return placer.place(); } @@ -1960,6 +1961,78 @@ class RadialOrbPlacer extends BaseOrbPlacer { } } +class RecursiveOrbPlacer extends BaseOrbPlacer { + doPlace(toPlace) { + this.toPlace = toPlace; + this.recDepth = 0; + + this.placeOne(0); + + return {}; + } + + placeOne() { + this.recDepth++; + if (this.recDepth > 2000) { + throw new Error("Too many backtracks"); + } + + let symbol = this.toPlace.pop(); + + let candidates = [[],[],[],[],[],[],[]]; + for (let n of this.template) { + if (!this.board.grid[n]) { + // is free + const cell = this.getCellInfo(n); + if (cell.freeSequence >= 3) { + candidates[cell.neighbours].push(n); + } + } + } + + for (let neighs = 6; neighs >= 0; neighs--) { + this.rng.arrayShuffle(candidates[neighs]); + } + + for (let i = 6; i >= 0; i--) { + if (candidates[i].length) { + for (let n of candidates[i]) { + this.placeOrb(n, symbol); + + // avoid deadlocking + if (this.solution.length % 2 === 0) { + // this is the second orb in a pair (solution starts with gold) + let prevn = this.solution[this.solution.length-1][1]; + if (!this.isAvailable(prevn)) { + this.trace("Avoid deadlock (this is the second of a pair)"); + this.removeOrb(n); + continue; + } + } + + this.solution.push([symbol, n]); + + if (this.toPlace.length) { + if (this.placeOne()) { + return true; + } else { + this.trace("Undo and continue"); + this.removeOrb(n); + this.solution.pop(); + // and continue the iteration + } + } else { + return true; + } + } + } + } + + // give it back + this.toPlace.push(symbol); + return false; + } +} /* Start */ From 98fc39027a4a156defc1320e411116e9112e9406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 15 Dec 2019 00:52:44 +0100 Subject: [PATCH 4/7] added new placement rules to make games more challenging --- script.js | 70 ++++++++++++++++++++++++++++++++++--------------------- style.css | 20 ++++++++++++++++ 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/script.js b/script.js index ea8297c..9e98f96 100644 --- a/script.js +++ b/script.js @@ -737,6 +737,7 @@ class Board { this.buttons.randomize = addButton(x0, y0, 'Randomize'); this.buttons.restart = addButton(x0, y0 + ysp, 'Try Again', 'disabled'); this.buttons.undo = addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); + this.buttons.working = addButton(x0, y0 + ysp*3, 'Working…', 'working'); const cfgy0 = 10; @@ -844,6 +845,29 @@ class Rng { arrayChoose(array) { return array[Math.floor(this.next() * array.length)]; } + + /** + * Sneak items in the given order into another array + * + * @param array - the modified array + * @param startKeepout - how many leading lements to leave untouched + * @param endKeepout - how many trailing elements to leave untouched + * @param items - items to sneak in + * @return the modified array + */ + arraySneak(array, startKeepout, endKeepout, items) { + let positions = []; + for (let i = 0; i < items.length; i++) { + positions.push(startKeepout + this.nextInt(array.length - startKeepout - endKeepout)) + } + positions.sort((a, b) => a - b); + // inject them into the array + items.forEach((pair, i) => { + array.splice(positions[i] + i, 0, pair); + }); + + return array; + } } class SettingsStorage { @@ -1044,7 +1068,7 @@ class Game { // Defer start to give browser time to render the background setTimeout(() => { - this.newGame(args.seed) + this.newGameWithLoader(args.seed) }, 50); } @@ -1407,12 +1431,12 @@ class Game { installButtonHandlers() { this.board.buttons.restart.addEventListener('click', () => { this.info("New Game with the same seed"); - this.newGame(this.rng.seed); + this.newGameWithLoader(this.rng.seed); }); this.board.buttons.randomize.addEventListener('click', () => { this.info("New Game with a random seed"); - this.newGame(+new Date); + this.newGameWithLoader(+new Date); }); this.board.buttons.btnAbout.addEventListener('click', () => { @@ -1488,6 +1512,15 @@ class Game { } } + newGameWithLoader(seed) { + this.board.buttons.working.classList.add('show'); + + setTimeout(() => { + this.newGame(seed); + this.board.buttons.working.classList.remove('show'); + }, 20); + } + newGame(seed) { if (seed !== null) { this.rng.setSeed(seed); @@ -1693,40 +1726,23 @@ class BaseOrbPlacer { toPlace.push(['salt', 'salt']); } - // these are always paired like this, and don't support salt - toPlace = toPlace.concat([ + // shuffle the pairs that have random order (i.e. not metals) + this.rng.arrayShuffle(toPlace); + + this.rng.arraySneak(toPlace, 3, 0, [ ['mors', 'vitae'], ['mors', 'vitae'], ['mors', 'vitae'], ['mors', 'vitae'], ]); - // shuffle the pairs that have random order (i.e. not metals) - this.rng.arrayShuffle(toPlace); - - // the order here is actually significant, so let's pay attention... - const metals = [ + this.rng.arraySneak(toPlace, 4, 0, [ ['lead', 'mercury'], ['tin', 'mercury'], ['iron', 'mercury'], ['copper', 'mercury'], ['silver', 'mercury'], - ]; - let mPos = []; - for (let i = 0; i < metals.length; i++) { - let x; - // find a unique position - do { - x = this.rng.nextInt(toPlace.length + i); - } while (mPos.includes(x)); - mPos.push(x) - } - mPos.sort((a, b) => a - b); - this.trace('Metal positions ', mPos); - // inject them into the array - metals.forEach((pair, i) => { - toPlace.splice(mPos[i] + i, 0, pair); - }); + ]); this.debug('Placement order (last first):', toPlace); @@ -1973,7 +1989,7 @@ class RecursiveOrbPlacer extends BaseOrbPlacer { placeOne() { this.recDepth++; - if (this.recDepth > 2000) { + if (this.recDepth > 1000) { throw new Error("Too many backtracks"); } diff --git a/style.css b/style.css index 1d2e6c4..705e8bb 100644 --- a/style.css +++ b/style.css @@ -122,6 +122,24 @@ text { stroke-width: 2px; cursor: pointer; } +.button-text.working, +.button-text.working:hover { + fill: #8d7761; + font-size: 26px; + opacity: 0; +} + +.button-text.working { + transition: opacity linear 0.2s; +} + +.cfg-no-anim .button-text.working { + transition: none; +} + +.button-text.working.show { + opacity: 1; +} .you-win { font-size: 80px; @@ -160,6 +178,8 @@ text { transform: translateY(1px); } + + .button-text.disabled, .button-text.disabled:hover, .button-text.disabled:active { From 4703ff81e7829c2617ba5957fad89d568bbe1ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 15 Dec 2019 00:59:23 +0100 Subject: [PATCH 5/7] remove old placer, change Try Again to do repeated retry (avoids the generation time) --- script.js | 164 ++---------------------------------------------------- 1 file changed, 5 insertions(+), 159 deletions(-) diff --git a/script.js b/script.js index 9e98f96..5220fe9 100644 --- a/script.js +++ b/script.js @@ -739,11 +739,10 @@ class Board { this.buttons.undo = addButton(x0, y0 + ysp*2, 'Undo', 'disabled'); this.buttons.working = addButton(x0, y0 + ysp*3, 'Working…', 'working'); - const cfgy0 = 10; + const cfgy0 = 10.5; this.buttons.optFancy = addButton(x0, cfgy0, 'Effects:', 'config'); this.buttons.optBlockedEffect = addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); - this.buttons.optSloppy = addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); this.buttons.toggleFullscreen = addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config'); this.buttons.btnAbout = addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config'); @@ -761,7 +760,6 @@ class Board { updateSettingsGUI(cfg) { this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off'); this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? 'On' : 'Off'); - this.buttons.optSloppy.textContent = 'Sloppy Mode: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off'); } } @@ -876,10 +874,8 @@ class SettingsStorage { this.defaults = { version: 1, log: 'info', - allowTemplateAugmenting: false, - retryTemplate: 120, + retryTemplate: 70, attemptTemplates: 20, - difficulty: 35, svgEffects: false, dimBlocked: true, logSolution: false, @@ -1238,7 +1234,6 @@ class Game { } placeOrbs(template) { - // let placer = new RadialOrbPlacer(this, template); let placer = new RecursiveOrbPlacer(this, template); return placer.place(); } @@ -1431,7 +1426,9 @@ class Game { installButtonHandlers() { this.board.buttons.restart.addEventListener('click', () => { this.info("New Game with the same seed"); - this.newGameWithLoader(this.rng.seed); + while (this.undoStack.length) { + this.undo(); + } }); this.board.buttons.randomize.addEventListener('click', () => { @@ -1467,13 +1464,6 @@ class Game { }) }); - this.board.buttons.optSloppy.addEventListener('click', () => { - this.info("Toggle sloppy placement mode"); - this.setCfg({ - allowTemplateAugmenting: !this.cfg.allowTemplateAugmenting, - }) - }); - this.board.buttons.toggleFullscreen.addEventListener('click', () => { this.info("Toggle Fullscreen"); if (document.fullscreenElement) { @@ -1833,150 +1823,6 @@ class BaseOrbPlacer { } } -/** - * Orb placement algorithm that starts in the center and places orbs in rings, with some - * small jitter allowed. - */ -class RadialOrbPlacer extends BaseOrbPlacer { - /** - * Find a candidate cell - * - * @param {number[]|null} except - indices to exclude - * @return {number} - */ - findBestCandidate(except = null) { - let candidates = []; - for (let n of this.template) { - if (except && except.includes(n)) continue; - - if (!this.board.grid[n]) { - // is free - const neigh = this.getCellInfo(n); - if (neigh.freeSequence >= 3) { - candidates.push({ - n, - cw: neigh.centerWeight - }); - } - } - } - - candidates.sort((a, b) => a.cw - b.cw); - - if (candidates.length) { - // return candidates[0].n; - let top = []; - let topw = candidates[0].cw; - for(let cand of candidates) { - if (cand.cw <= topw + this.cfg.difficulty) { // TODO this could vary by template - top.push(cand); - } - } - - //the neighbor count is not used for anything anymore, oops - - // console.log('Got a choice of '+top.length+' tiles'); - - return this.rng.arrayChoose(top).n; - } - - return false; - } - - /** - * Find index for next placement - * - * @param {number[]|null} except - indices to exclude - * @return {number} - index - */ - findAvailableIndex(except = null) { - const n = this.findBestCandidate(except); - if (n !== false) return n; - - // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. - if (this.template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { - // Prefer tile with more neighbours to make the game harder - let candidates = []; - this.outsideTemplate.forEach((n) => { - if (!this.templateMap[n] && this.isAvailable(n)) { - const neigh = this.getCellInfo(n); - if (!candidates[neigh.neighbours]) { - candidates[neigh.neighbours] = [n]; - } else { - candidates[neigh.neighbours].push(n); - } - } - }); - - for (let i = 6; i >= 0; i--) { - if (!candidates[i]) continue; - let toAdd = this.rng.arrayChoose(candidates[i]); - this.templateMap[toAdd] = true; - this.outsideTemplate.splice(this.outsideTemplate.indexOf(toAdd), 1); - - this.template.push(toAdd); - this.tilesAdded++; - - this.warn(`Adding extra tile to template: ${toAdd}`); - return toAdd; - } - } - - throw Error("Failed to find available board tile."); - } - - doPlace(toPlace) { - this.tilesAdded = 0; - - while (toPlace.length > 0) { - this.trace('placing a pair.'); - - let symbol1 = toPlace.pop(); - let index1 = this.findAvailableIndex(); - this.placeOrb(index1, symbol1); - this.solution.push([symbol1, index1]); - - let symbol2 = toPlace.pop(); - let index2 = this.findAvailableIndex(); - this.placeOrb(index2, symbol2); - - if (!this.isAvailable(index1)) { - this.debug(`Deadlock, trying to work around it - ${index1}, ${index2}`); - - this.removeOrb(index2); - let except = [index2]; - - let suc = false; - for (let i = 0; i < 5; i++) { - this.trace(`try #${i + 1}`); - let index = this.findAvailableIndex(except); - this.placeOrb(index, symbol2); - - if (this.isAvailable(index1)) { - suc = true; - index2 = index; - break; - } else { - this.removeOrb(index); - except.push(index); - } - } - - if (!suc) { - throw new Error("Solution contains a deadlock."); - } - } - - // index2 is updated in the fixing loop - this.solution.push([symbol2, index2]); - } - - return { - tilesAdded: this.tilesAdded, - }; - } -} - class RecursiveOrbPlacer extends BaseOrbPlacer { doPlace(toPlace) { this.toPlace = toPlace; From 4240282b17e4448aabcdef65e860e0dd4d886d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 15 Dec 2019 00:59:40 +0100 Subject: [PATCH 6/7] bump scripts --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 97b7b7c..93f7192 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Sigmar's Garden Online - + @@ -90,6 +90,6 @@ - + From 7d8c1bfc4b2a6f016c73efdbc1db249e212441fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 15 Dec 2019 01:04:17 +0100 Subject: [PATCH 7/7] update readme --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1ce1e2c..0b5b469 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,6 @@ or changed without updating this reference. Search for `SettingsStorage`. ``` { log: 'info', // default log level - allowTemplateAugmenting: false, // sloppy mode retryTemplate: 30, // retry attempts within one template attemptTemplates: 50, // number of templates to try on failure svgEffects: false, // fancy mode @@ -125,19 +124,20 @@ Algorithm Quirks Every board must be solvable, otherwise it wouldn't be much of a game. Generating a valid board turned out quite a bit more challenging than I thought. My algorithm is based in some heuristics -and good deal of luck. +and luck. To make things more fun, besides the marble matching rules, the board must be laid out in one of several pre-defined shapes of exactly 55 tiles. The algorithm can sometimes get itself into a dead -end while placing marbles on the board. I experimented with backtracking, but the current version -simply retries the whole thing with a different random seed. There are configurable retry limits -as a failsafe. If the given template fails many consecutive times, the algorithm opts to switch to +end while placing marbles on the board. After some experimentation I settled on a recursive algorithm +with a heuristic and backtracking. There are configurable retry limits and max recursion / retry +counter as a failsafe. If the given template fails many consecutive times, the algorithm opts to switch to a different template. Some templates are harder than others, and some random seeds just seem to have a problem. -A workaround I added is called Sloppy Mode. When the algorithm can't place a marble, it may choose -to add a new tile to the template, trying to keep the overall shape as tidy as possible. This may -hurt game difficulty, but is generally much faster than retrying over and over. +I added a few extra rules to the generation algorithm to make the boards harder: Vitae and Mors should not +appear as the first few marbles to remove, since they tend to eliminate any choice; and metals start a few +levels deeper still. This gives the player a lot more room to make a mistake in the beginning. +At least you can enjoy the Undo and Try Again buttons! If you're curious about the inner workings, open dev tools and enable debug logging with the `debug=1` GET parameter.