Merge branch 'recursive'

master
Ondřej Hruška 5 years ago
commit 6a0a233671
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 16
      README.md
  2. 4
      index.html
  3. 866
      script.js
  4. 20
      style.css

@ -94,7 +94,6 @@ or changed without updating this reference. Search for `SettingsStorage`.
``` ```
{ {
log: 'info', // default log level log: 'info', // default log level
allowTemplateAugmenting: false, // sloppy mode
retryTemplate: 30, // retry attempts within one template retryTemplate: 30, // retry attempts within one template
attemptTemplates: 50, // number of templates to try on failure attemptTemplates: 50, // number of templates to try on failure
svgEffects: false, // fancy mode 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 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 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 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 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 end while placing marbles on the board. After some experimentation I settled on a recursive algorithm
simply retries the whole thing with a different random seed. There are configurable retry limits with a heuristic and backtracking. There are configurable retry limits and max recursion / retry
as a failsafe. If the given template fails many consecutive times, the algorithm opts to switch to 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 a different template. Some templates are harder than others, and some random seeds just seem to
have a problem. have a problem.
A workaround I added is called Sloppy Mode. When the algorithm can't place a marble, it may choose I added a few extra rules to the generation algorithm to make the boards harder: Vitae and Mors should not
to add a new tile to the template, trying to keep the overall shape as tidy as possible. This may appear as the first few marbles to remove, since they tend to eliminate any choice; and metals start a few
hurt game difficulty, but is generally much faster than retrying over and over. 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` If you're curious about the inner workings, open dev tools and enable debug logging with the `debug=1`
GET parameter. GET parameter.

@ -8,7 +8,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>Sigmar's Garden Online</title> <title>Sigmar's Garden Online</title>
<link rel="stylesheet" href="style.css?cache=2019-12-10b"> <link rel="stylesheet" href="style.css?cache=2019-12-15">
<meta name="author" content="Ondřej Hruška"> <meta name="author" content="Ondřej Hruška">
<meta name="description" content="Play Sigmar's Garden online. Opus Magnum minigame re-implemented in JavaScript and SVG."> <meta name="description" content="Play Sigmar's Garden online. Opus Magnum minigame re-implemented in JavaScript and SVG.">
@ -90,6 +90,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="script.js?cache=2019-12-10b"></script> <script src="script.js?cache=2019-12-15"></script>
</body> </body>
</html> </html>

@ -262,6 +262,15 @@ class Board {
this.removeOrbByIndex(index) 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 * Remove orb by array index
* *
@ -642,41 +651,115 @@ class Board {
return this.grid[n]; 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() { buildGui() {
const addButton = (x, y, text, classes='') => {
let { rx, ry } = this.guiXyToCoord(x, y);
let button = Svg.fromXML(`<text class="button-text${classes?' '+classes:''}" x="${rx}" y="${ry}">${text}</text>`);
this.$root.appendChild(button);
return button;
};
const y0 = 0.05; const y0 = 0.05;
const x0 = 0; const x0 = 0;
const ysp = 0.75; const ysp = 0.75;
const ysp2 = 0.6; const ysp2 = 0.6;
this.buttons.randomize = this.addButton(x0, y0, 'Randomize'); this.buttons.randomize = addButton(x0, y0, 'Randomize');
this.buttons.restart = this.addButton(x0, y0 + ysp, 'Try Again', 'disabled'); this.buttons.restart = addButton(x0, y0 + ysp, 'Try Again', 'disabled');
this.buttons.undo = this.addButton(x0, y0 + ysp*2, 'Undo', '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.optFancy = addButton(x0, cfgy0, 'Effects:', 'config');
this.buttons.optBlockedEffect = this.addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config'); this.buttons.optBlockedEffect = addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config');
this.buttons.optSloppy = this.addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); this.buttons.toggleFullscreen = addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config');
this.buttons.toggleFullscreen = this.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(`<text class="you-win" opacity="0" x="0" y="0">Good work!</text>`); let youWin = Svg.fromXML(`<text class="you-win" opacity="0" x="0" y="0">Good work!</text>`);
this.$root.appendChild(youWin); this.$root.appendChild(youWin);
this.youWin = youWin; this.youWin = youWin;
} }
/**
* Update toggles
*
* @param cfg
*/
updateSettingsGUI(cfg) { updateSettingsGUI(cfg) {
this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off'); this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off');
this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? '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 class="button-text${classes?' '+classes:''}" x="${rx}" y="${ry}">${text}</text>`);
this.$root.appendChild(button);
return button;
} }
} }
@ -733,6 +816,56 @@ class Rng {
nextInt(max) { nextInt(max) {
return Math.floor((max + 1) * this.next()); 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 { class SettingsStorage {
@ -741,13 +874,12 @@ class SettingsStorage {
this.defaults = { this.defaults = {
version: 1, version: 1,
log: 'info', log: 'info',
allowTemplateAugmenting: false, retryTemplate: 70,
retryTemplate: 120,
attemptTemplates: 20, attemptTemplates: 20,
difficulty: 35,
svgEffects: false, svgEffects: false,
dimBlocked: true, dimBlocked: true,
logSolution: false, logSolution: false,
highlightTemplate: false,
}; };
this.settings = Object.assign({}, this.defaults); this.settings = Object.assign({}, this.defaults);
} }
@ -932,7 +1064,7 @@ class Game {
// Defer start to give browser time to render the background // Defer start to give browser time to render the background
setTimeout(() => { setTimeout(() => {
this.newGame(args.seed) this.newGameWithLoader(args.seed)
}, 50); }, 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) { placeOrbs(template) {
let tilesAdded = 0; let placer = new RecursiveOrbPlacer(this, template);
return placer.place();
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;
}, []);
} }
getPairSymbols(first) { getPairSymbols(first) {
@ -1500,7 +1283,7 @@ class Game {
* @return {Boolean} * @return {Boolean}
*/ */
isAvailableAtPlaytime(n) { isAvailableAtPlaytime(n) {
let ava = this.isAvailable(n); let ava = this.board.isAvailable(n);
const sym = this.board.grid[n].symbol; const sym = this.board.grid[n].symbol;
if (METAL_SEQ.includes(sym)) { if (METAL_SEQ.includes(sym)) {
@ -1623,7 +1406,7 @@ class Game {
} }
if (wantRefresh) { if (wantRefresh) {
if (this.countOrbs() === 0) { if (this.board.countOrbs() === 0) {
this.info("Good work!"); this.info("Good work!");
if (removed) { if (removed) {
@ -1643,12 +1426,14 @@ class Game {
installButtonHandlers() { installButtonHandlers() {
this.board.buttons.restart.addEventListener('click', () => { this.board.buttons.restart.addEventListener('click', () => {
this.info("New Game with the same seed"); 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.board.buttons.randomize.addEventListener('click', () => {
this.info("New Game with a random seed"); this.info("New Game with a random seed");
this.newGame(+new Date); this.newGameWithLoader(+new Date);
}); });
this.board.buttons.btnAbout.addEventListener('click', () => { 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.board.buttons.toggleFullscreen.addEventListener('click', () => {
this.info("Toggle Fullscreen"); this.info("Toggle Fullscreen");
if (document.fullscreenElement) { if (document.fullscreenElement) {
@ -1700,7 +1478,7 @@ class Game {
* Update button hiding attributes, disabled orb effects, etc * Update button hiding attributes, disabled orb effects, etc
*/ */
updateGameGui() { updateGameGui() {
let nOrbs = this.countOrbs(); let nOrbs = this.board.countOrbs();
this.board.buttons.restart this.board.buttons.restart
.classList.toggle('disabled', nOrbs === 55); .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) { newGame(seed) {
if (seed !== null) { if (seed !== null) {
this.rng.setSeed(seed); this.rng.setSeed(seed);
@ -1773,11 +1560,7 @@ class Game {
break; break;
} catch (e) { } catch (e) {
retry_count++; retry_count++;
if (this.logging_trace) { this.warn(e.message);
this.error(e);
} else {
this.warn(e.message);
}
} }
} }
@ -1792,10 +1575,7 @@ class Game {
if (!suc) { if (!suc) {
alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`); alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`);
} else { } else {
this.info( this.info(`Board set up with ${retry_count} retries.`);
`Board set up with ${retry_count} retries:\n` +
`teplate "${board_info.template.name}"` +
(this.cfg.allowTemplateAugmenting ? ` with ${board_info.tilesAdded} extra tiles` : ''));
if (this.cfg.logSolution) { if (this.cfg.logSolution) {
this.info('Reference solution:\n ' + board_info.solution.reduce((s, entry, i) => { 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 */ /* Start */
window.game = new Game(); window.game = new Game();

@ -122,6 +122,24 @@ text {
stroke-width: 2px; stroke-width: 2px;
cursor: pointer; 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 { .you-win {
font-size: 80px; font-size: 80px;
@ -160,6 +178,8 @@ text {
transform: translateY(1px); transform: translateY(1px);
} }
.button-text.disabled, .button-text.disabled,
.button-text.disabled:hover, .button-text.disabled:hover,
.button-text.disabled:active { .button-text.disabled:active {

Loading…
Cancel
Save