diff --git a/script.js b/script.js index 03b16ee..31c69e6 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,5 @@ +!(function() { + class Svg { /** * Build a node from XML @@ -38,6 +40,60 @@ class Svg { } } +/* --------- Shared Constants --------- */ +const BOARD_SIZE = 121; + +const METAL_SEQ = ['lead', 'tin', 'iron', 'copper', 'silver', 'gold']; + +const SYMBOLS_METALS = [ + 'mercury', 'lead', 'tin', 'iron', 'copper', 'silver', 'gold' +]; + +const SYMBOLS_ALL = [ + 'salt', 'air', 'fire', 'water', 'earth', 'mercury', 'lead', + 'tin', 'iron', 'copper', 'silver', 'gold', 'mors', 'vitae' +]; + +/** + * Convert grid coordinates to gameGrid array index + * + * @param {Number} x + * @param {Number} y + * @returns {Number} + */ +function xyToGridIndex(x, y) { + return y * 11 + x +} + +/** + * Convert grid index to X, Y + * + * @param {Number} index + * @returns {{x: Number, y: Number}} + */ +function gridIndexToXy(index) { + return { + x: index % 11, + y: Math.floor(index / 11) + }; +} + +/** + * Get if a coordinate is outside the game board. + * + * @param x + * @param y + * @return {boolean|boolean} + */ +function isXyOutside(x, y) { + return x < 0 + || x > 10 + || y < 0 + || y > 10 + || (y <= 5 && x > 5 + y) + || (y > 5 && x < y - 5); +} + /** * Game board * @@ -59,12 +115,10 @@ class Board { // Orb grid this.grid = []; this.tiles = []; - this.indexXyLookup = []; - for (let i = 0; i <= 120; i++) { + for (let i = 0; i < BOARD_SIZE; i++) { this.grid[i] = null; this.tiles[i] = null; - this.indexXyLookup[i] = {x: i % 11, y: Math.floor(i / 11)}; } this.onOrbClick = (index, orb) => { @@ -147,32 +201,9 @@ class Board { * @param {String|null} symbol - symbol to highlight, null to hide highlights */ highlight(symbol = null) { - this.$svg.setAttribute('class', ''); - - if (symbol !== null) { - this.$svg.classList.add(`highlight-${symbol}`); - } - } - - /** - * Convert grid coordinates to gameGrid array index - * - * @param {Number} x - * @param {Number} y - * @returns {Number} - */ - xyToGridIndex(x, y) { - return y * 11 + x - } - - /** - * Convert grid index to X, Y - * - * @param {Number} index - * @returns {{x: Number, y: Number}} - */ - gridIndexToXy(index) { - return this.indexXyLookup[index]; + SYMBOLS_ALL.forEach((s) => { + this.$svg.classList.toggle(`highlight-${symbol}`, symbol === s); + }); } /** @@ -195,7 +226,7 @@ class Board { * @param {Number} y - board Y */ removeOrb(x, y) { - const index = this.xyToGridIndex(x, y); + const index = xyToGridIndex(x, y); this.removeOrbByIndex(index) } @@ -230,7 +261,7 @@ class Board { * @return {{node: Node, symbol: String}} */ placeOrbByIndex(index, symbol) { - const {x, y} = this.gridIndexToXy(index); + const {x, y} = gridIndexToXy(index); return this.placeOrb(x, y, symbol); } @@ -244,12 +275,12 @@ class Board { */ placeOrb(x, y, symbol) { const {rx, ry} = this.gridXyToCoord(x, y); - const arrayIndex = this.xyToGridIndex(x, y); + const arrayIndex = xyToGridIndex(x, y); this.removeOrbByIndex(arrayIndex); let template; - if (this.metals.includes(symbol)) { + if (SYMBOLS_METALS.includes(symbol)) { template = this.metallicOrbTpl; } else { template = this.orbTpl; @@ -479,7 +510,7 @@ class Board { polygon_shadow.setAttribute('transform', `translate(${rx},${ry}),scale(1.1)`); this.buf0.push(polygon_shadow); - const index = this.xyToGridIndex(x, y); + const index = xyToGridIndex(x, y); let tile = this.tileTpl.cloneNode(true); tile.setAttribute('transform', `translate(${rx},${ry})`); this.buf1.push(tile); @@ -512,16 +543,6 @@ class Board { vitae: 'm 11.975898,274.8189 0.358077,0.35808 v 2.86461 H 9.4693607 v 1.79038 h 2.8646143 v 3.93885 H 6.9628227 l 6.4453823,8.95192 6.087306,-8.95192 H 14.12436 v -3.93885 h 2.864613 v -1.79038 H 14.12436 v -2.86461 l 0.358076,-0.35808 z m -1.790384,10.38422 h 6.445383 l -3.222692,4.65501 z', }; - this.symbols = [ - 'salt', 'air', 'fire', 'water', 'earth', 'mercury', 'lead', - 'tin', 'iron', 'copper', 'silver', 'gold', 'mors', 'vitae' - ]; - this.metals = [ - 'mercury', 'lead', 'tin', 'iron', 'copper', 'silver', 'gold' - ]; - - this.metalSequence = ['lead', 'tin', 'iron', 'copper', 'silver', 'gold']; - this.orbColors = { salt: '#f2e7b4', air: '#9ee0ff', @@ -632,11 +653,61 @@ class Rng { } } +class SettingsStorage { + constructor() { + // this object is never overwritten, references are stable + this.settings = { + debug: false, + version: 1, + allowTemplateAugmenting: true, + retryTemplate: 30, + attemptTemplates: 50, + animations: true, + disabledEffect: true, + }; + } + + load() { + let saved = localStorage.getItem('sigmar_settings'); + if (saved) { + let parsed; + try { + parsed = JSON.parse(saved); + + // XXX some validation / version conversion could be done here + + delete parsed.version; + + Object.assign(this.settings, parsed); + } catch (e) { + console.error("Error loading settings:", e); + } + } + + return this.settings; + } + + update(update) { + Object.assign(this.settings, update); + return this.settings; + } + + save() { + localStorage.setItem('sigmar_settings', JSON.stringify(this.settings)); + } +} + class Game { /** * Init the game */ constructor(seed = null) { + this.settingsStore = new SettingsStorage(); + this.cfg = this.settingsStore.load(); + this.debug("Game settings:", this.cfg); + + // TODO take seed from hash + this.board = new Board(); if (seed === null) { seed = +new Date(); @@ -674,9 +745,42 @@ class Game { 'frisbee': [0, 11, 12, 13, 14, 15, 22, 25, 26, 27, 28, 29, 33, 34, 36, 38, 39, 40, 41, 45, 46, 47, 49, 50, 53, 57, 58, 59, 60, 62, 64, 65, 68, 69, 72, 74, 75, 80, 81, 82, 83, 84, 85, 86, 92, 95, 96, 97, 104, 106, 107, 115, 116, 117, 118], }; + this.applySettings(); + setTimeout(() => this.newGame(), 100); } + debug(...args) { + if (this.cfg.debug) console.log(...args); + } + + info(...args) { + console.info(...args); + } + + warn(...args) { + console.warn(...args); + } + + error(...args) { + console.error(...args); + } + + applySettings() { + this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.animations); + this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.disabledEffect); + } + + setCfg(update) { + this.settingsStore.update(update); + this.applySettings(); + this.settingsStore.save(); + } + + getCfg(key) { + return this.cfg[key]; + } + /** * Show a selected template, for debug * @@ -751,8 +855,8 @@ class Game { return tpl .sort((a, b) => a - b) .map((n) => { - let {x, y} = this.board.gridIndexToXy(n); - return this.board.xyToGridIndex(5 + y - x, y); + let {x, y} = gridIndexToXy(n); + return xyToGridIndex(5 + y - x, y); }) .sort((a, b) => a - b); } @@ -788,29 +892,20 @@ class Game { }; } - isOutside(x, y) { - return x < 0 - || x > 10 - || y < 0 - || y > 10 - || (y <= 5 && x > 5 + y) - || (y > 5 && x < y - 5); - } - isAvailable(n) { return this.getNeighbours(n).freeSequence >= 3; } getNeighbours(n) { - let {x, y} = this.board.gridIndexToXy(n); + let {x, y} = gridIndexToXy(n); let freeSpaces = [ - this.isOutside(x - 1, y) || !this.board.grid[n - 1], - this.isOutside(x - 1, y - 1) || !this.board.grid[n - 12], - this.isOutside(x, y - 1) || !this.board.grid[n - 11], - this.isOutside(x + 1, y) || !this.board.grid[n + 1], - this.isOutside(x + 1, y + 1) || !this.board.grid[n + 12], - this.isOutside(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], + 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; @@ -820,8 +915,6 @@ class Game { } } - // if(this.debuggetneigh) console.log(`${x}×${y} #${n}, nocc ${nOccupied} `+JSON.stringify(freeSpaces)); - let freeSequence = 0; let maxFreeSequence = 0; for (let i = 0; i < 12; i++) { @@ -847,12 +940,12 @@ class Game { let allowedTable = []; let outsideTemplate = []; - for (let i = 0; i <= 120; i++) { - let allo = template.includes(i); + for (let i = 0; i < BOARD_SIZE; i++) { + const allo = template.includes(i); allowedTable.push(allo); - let { x, y } = this.board.gridIndexToXy(i); - if (!allo && !this.isOutside(x, y)) { + let { x, y } = gridIndexToXy(i); + if (!allo && !isXyOutside(x, y)) { outsideTemplate.push(i); } @@ -868,7 +961,7 @@ class Game { } const place = (n, symbol) => { - console.log(`Place ${n} <- ${symbol}`); + this.debug(`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 @@ -876,7 +969,7 @@ class Game { }; const unplace = (n) => { - console.log(`Unplace ${n}`); + this.debug(`Unplace ${n}`); this.board.grid[n] = null; }; @@ -908,7 +1001,7 @@ class Game { } // this corrupts the template, but makes the likelihood of quickly finding a valid solution much higher. - if (template.length !== 121) { + if (template.length !== BOARD_SIZE && this.cfg.allowTemplateAugmenting) { // Prefer tile with more neighbours to make the game harder let candidates = []; outsideTemplate.forEach((n) => { @@ -930,7 +1023,7 @@ class Game { template.push(toAdd); - console.warn(`Adding extra tile to template: ${toAdd}`); + this.warn(`Adding extra tile to template: ${toAdd}`); return toAdd; } } @@ -944,7 +1037,7 @@ class Game { let solution = []; while (toPlace.length > 0) { - console.log('placing a pair.'); + this.debug('placing a pair.'); let symbol1 = toPlace.pop(); let index1 = findAvailableIndex(); @@ -955,16 +1048,15 @@ class Game { place(index2, symbol2); if (!this.isAvailable(index1)) { - console.warn(`Deadlock, trying to work around it - ${index1}, ${index2}`); + 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++) { - console.log(`try #${i + 1}`); + this.debug(`try #${i + 1}`); let index = findAvailableIndex(except); - // console.log(`try ${index} instead of ${index2}`); place(index, symbol2); if (this.isAvailable(index1)) { @@ -985,16 +1077,17 @@ class Game { solution.push([symbol2, index2]); } - solution.reverse(); + // Show the solution for debug - console.info("Found a valid board!"); + solution.reverse(); + this.info("Found a valid board!"); solution.forEach((a) => { - let p = this.board.gridIndexToXy(a[1]); + let p = gridIndexToXy(a[1]); a[1] = `${p.x} × ${p.y}`; }); - console.log('Solution: ', solution); + this.debug('Solution: ', solution); } /** @@ -1069,7 +1162,7 @@ class Game { while (true) { const n = this.rng.nextInt(toPlace.length - 1); if (toPlace[n][1] !== 'salt') { - // console.log(`Pairing ${toPlace[n][1]} with salt.`); + this.debug(`Pairing ${toPlace[n][1]} with salt.`); newSaltedPairs.push([toPlace[n][1], 'salt']); toPlace[n][1] = 'salt'; break; @@ -1111,7 +1204,7 @@ class Game { mPos.push(x) } mPos.sort((a, b) => a - b); - // console.log('Metal positions ', mPos); + this.debug('Metal positions ', mPos); // inject them into the array metals.forEach((pair, i) => { toPlace.splice(mPos[i] + i, 0, pair); @@ -1128,7 +1221,7 @@ class Game { * 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 <= 120; n++) { + 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; @@ -1141,7 +1234,7 @@ class Game { * Update orb availability status (includes effects) */ updateOrbDisabledStatus() { - for (let n = 0; n <= 120; n++) { + for (let n = 0; n < BOARD_SIZE; n++) { if (this.board.grid[n]) { const ava = this.isAvailableAtPlaytime(n); this.board.grid[n].node.classList.toggle('disabled', !ava); @@ -1153,13 +1246,13 @@ class Game { * Check if a tile is available at play-time (checking unlocked metals) * * @param n - * @return {boolean} + * @return {Boolean} */ isAvailableAtPlaytime(n) { let ava = this.isAvailable(n); const sym = this.board.grid[n].symbol; - if (this.board.metalSequence.includes(sym)) { + if (METAL_SEQ.includes(sym)) { if (sym !== this.nextMetal) { ava = false; } @@ -1170,6 +1263,7 @@ class Game { /** * Handle orb click + * * @param n * @param orb */ @@ -1222,7 +1316,7 @@ class Game { if (wantRefresh) { if (this.countOrbs() === 0) { - console.info("Good work!"); + this.info("Good work!"); } this.updateOrbDisabledStatus(); @@ -1230,26 +1324,23 @@ class Game { } newGame() { - // this.board.onTileClick = (n) => { - // console.log(n, this.board.gridIndexToXy(n)); - // }; + this.board.onTileClick = (n) => { + this.debug(n, gridIndexToXy(n)); + }; this.selectedOrb = null; let self = this; this.board.onOrbClick = (n, orb) => self.ingameBoardClick(n, orb); - const RETRY_IN_TEMPLATE = 50; - const RETRY_NEW_TEMPLATE = 50; - // retry loop, should not be needed if everything is correct let suc = false; let numretries = 0; const alertOnError = false; - for (let i = 0; i < RETRY_NEW_TEMPLATE && !suc; i++) { - console.log('RNG seed is: ' + this.rng.state); + for (let i = 0; i < this.cfg.attemptTemplates && !suc; i++) { + this.debug('RNG seed is: ' + this.rng.state); const template = this.getRandomTemplate(); - for (let j = 0; j < RETRY_IN_TEMPLATE; j++) { + for (let j = 0; j < this.cfg.retryTemplate; j++) { try { this.placeOrbs(template.slice(0)); // clone suc = true; @@ -1257,11 +1348,15 @@ class Game { } catch (e) { if (alertOnError) alert('welp'); numretries++; - console.error(e); + if (this.cfg.debug) { + this.error(e); + } else { + this.warn(e.message); + } } } if (!suc) { - console.warn("Exhausted all retries for the template, getting a new one"); + this.warn("Exhausted all retries for the template, getting a new one"); } } @@ -1272,7 +1367,7 @@ class Game { if (!suc) { alert(`Sorry, could not find a valid board setup after ${numretries} retries.`); } else { - console.info(`Found valid solution (with ${numretries} retries)`); + this.info(`Found valid solution (with ${numretries} retries)`); } } @@ -1296,7 +1391,8 @@ class Game { } advanceMetal() { - this.nextMetal = this.board.metalSequence[this.board.metalSequence.indexOf(this.nextMetal) + 1]; + 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}`); } } @@ -1304,3 +1400,5 @@ class Game { /* Start */ window.game = new Game(); + +})(); diff --git a/style.css b/style.css index 851f60c..2eec271 100644 --- a/style.css +++ b/style.css @@ -56,11 +56,19 @@ html,body { opacity: 0.6; } +.cfg-no-fade-disabled .orb.disabled { + opacity: 1; +} + .orb-glow, .orb-shadow { transition: opacity linear 0.1s; } +.cfg-no-anim * { + transition: none !important; +} + .orb.selected .orb-glow, .orb:hover .orb-glow { opacity: 1;