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. 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 @@ - + diff --git a/script.js b/script.js index 3d7e1f8..5220fe9 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,41 +651,115 @@ 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'); + this.buttons.working = addButton(x0, y0 + ysp*3, 'Working…', 'working'); - const cfgy0 = 10; + const cfgy0 = 10.5; - 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.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 +816,56 @@ 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)]; + } + + /** + * 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 { @@ -741,13 +874,12 @@ class SettingsStorage { this.defaults = { version: 1, log: 'info', - allowTemplateAugmenting: false, - retryTemplate: 120, + retryTemplate: 70, attemptTemplates: 20, - difficulty: 35, svgEffects: false, dimBlocked: true, logSolution: false, + highlightTemplate: false, }; this.settings = Object.assign({}, this.defaults); } @@ -932,7 +1064,7 @@ class Game { // Defer start to give browser time to render the background setTimeout(() => { - this.newGame(args.seed) + this.newGameWithLoader(args.seed) }, 50); } @@ -1101,358 +1233,9 @@ class Game { }; } - isAvailable(n) { - return this.getNeighbours(n).freeSequence >= 3; - } - - getNeighbours(n) { - let {x, y} = gridIndexToXy(n); - - 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], - ]; - - 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.board.gridXyToCoord(x, y); - - return { - neighbours: nOccupied, - freeSequence: maxFreeSequence, - centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))), - }; - } - placeOrbs(template) { - let tilesAdded = 0; - - this.board.removeAllOrbs(); - - let allowedTable = []; - let outsideTemplate = []; - for (let i = 0; i < BOARD_SIZE; i++) { - const allo = template.includes(i); - allowedTable.push(allo); - - let {x, y} = gridIndexToXy(i); - if (!allo && !isXyOutside(x, y)) { - outsideTemplate.push(i); - } - - // Highlight pattern shape - - // if (this.board.tiles[i]) { - // if (allo) { - // this.board.tiles[i].setAttribute('opacity', 1) - // } else { - // this.board.tiles[i].setAttribute('opacity', 0.6) - // } - // } - } - - 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; - }; - - 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 - }); - } - } - } - - 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.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; - }, []); + let placer = new RecursiveOrbPlacer(this, template); + return placer.place(); } getPairSymbols(first) { @@ -1500,7 +1283,7 @@ class Game { * @return {Boolean} */ isAvailableAtPlaytime(n) { - let ava = this.isAvailable(n); + let ava = this.board.isAvailable(n); const sym = this.board.grid[n].symbol; if (METAL_SEQ.includes(sym)) { @@ -1623,7 +1406,7 @@ class Game { } if (wantRefresh) { - if (this.countOrbs() === 0) { + if (this.board.countOrbs() === 0) { this.info("Good work!"); if (removed) { @@ -1643,12 +1426,14 @@ class Game { installButtonHandlers() { this.board.buttons.restart.addEventListener('click', () => { this.info("New Game with the same seed"); - this.newGame(this.rng.seed); + while (this.undoStack.length) { + this.undo(); + } }); 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', () => { @@ -1679,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) { @@ -1700,7 +1478,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); @@ -1724,6 +1502,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); @@ -1773,11 +1560,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 +1575,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 +1596,306 @@ class Game { } } +/** + * Base class for orb placer. + */ +class BaseOrbPlacer { + constructor(game, template) { + this.template = template; + this.rng = game.rng; + this.cfg = game.cfg; + this.game = game; + 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`); + } + 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; + } + + /** + * Remove an orb, if any. + * + * @param n - position + */ + removeOrb(n) { + let old = this.board.grid[n]; + this.board.grid[n] = null; + this.trace(`Unplace ${n} (${old})`); + 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); + } + + 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); + } + + /** + * 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'], + ['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']); + } + + // 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'], + ]); + + this.rng.arraySneak(toPlace, 4, 0, [ + ['lead', 'mercury'], + ['tin', 'mercury'], + ['iron', 'mercury'], + ['copper', 'mercury'], + ['silver', 'mercury'], + ]); + + this.debug('Placement order (last first):', toPlace); + + return toPlace.reduce((a, c) => { + a.push(c[0]); + a.push(c[1]); + return a; + }, []); + } + + /** + * Run the placement logic. + */ + 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 toPlace = this.buildPlacementList(); + let rv = this.doPlace(toPlace); + + 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; + } + + /** + * 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"); + } +} + +class RecursiveOrbPlacer extends BaseOrbPlacer { + doPlace(toPlace) { + this.toPlace = toPlace; + this.recDepth = 0; + + this.placeOne(0); + + return {}; + } + + placeOne() { + this.recDepth++; + if (this.recDepth > 1000) { + 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 */ window.game = new Game(); 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 {