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();