From 0311126369569f93308f4794a4ac69e34ab3346f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 8 Dec 2019 18:55:24 +0100 Subject: [PATCH] add readme and some fixing --- README.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 14 +++++- script.js | 106 +++++++++++++++++++++++++++++++------------- 3 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..87e0512 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +Sigmar's Garden +=============== + +This project implements the Sigmar's Garden mini-game from Zachronics' [Opus Magnum][om] +in HTML5, JavaScript, and SVG. + +**Play on-line at [https://bits.ondrovo.com/sigmar](https://bits.ondrovo.com/sigmar).** + +The game is fully client-side and static, you can simply download it and open locally as well. + +Game rules +---------- + +There are 55 marbles on the board, composed as follows: + +- 8 pieces of the four Elements: Air, Water, Fire, and Earth (total of 32) +- 4 pieces of Salt +- 5 metals and 1 Gold +- 5 pieces of Mercury +- 4 pairs of Vitae and Mors + +Your goal is to clear the board. + +- Vitae reacts only with Mors +- Elements react with the same element kind, or with Salt +- Salt reacts with Elements or with itself +- Metals react with Mercury, but they unlock one by one, from the most common Lead to Gold. +- Gold is removed by itself as the last metal + +The metals sequence is as follows: + +1. Lead + Mercury +2. Tin + Mercury +3. Iron + Mercury +4. Copper + Mercury +5. Silver + Mercury +6. Gold + +Here is a diagram from the original game *(hosted on [Steam Community][ruleslink])* + +![Rules sheet][rulespic] + +User Interface Explanation +-------------------------- + +The user interface contains a few buttons and toggles: + +- *Randomize* - start a new game with a random shape and marble placement +- *Try Again* - reset the current game to its initial arrangement +- *Undo* - reverts one game action (reaction of two marbles). There is no limit on how many + steps you can undo. + +The bottom left corner contains settings: + +- *Effects* - enable graphic effects that look pretty but perform poorly on mobile phones or + browsers without hardware acceleration. +- *Dim Blocked* - highlight marbles that can be played by dimming the others. This makes the game + much harder, but is more realistic. +- *Sloppy Gen* - allow sloppy board filling. Use if building the board takes too long on + your computer. May result in uglier and/or easier boards. + +Settings +-------- + +The game saves its persistent settings in your browser's [localStorage][localStorage]. + +Settings can be manipulated through the GUI, through some GET parameters, and via JavaScript API +in dev tools. The function of interest is called `game.setCfg({key: value, ...})`. + +Here's the settings object. Please refer to the source code, as this is internal API and may be extended +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 + dimBlocked: true, // highlight active marbles +} +``` + +GET Arguments +------------- + +The game can be parametrised by GET arguments you add to the URL. +This can be used for bookmarks or to share a particular board. + +- `debug=1/0` - enable debug, or disable debug and trace logging levels +- `trace=1/0` - toggle trace logging level +- `log=level` - select logging level: trace, debug, info, warn, error +- `seed=123` - set random seed, must be numeric. Share the current board by copying the URL +- `rnd=1` - don't put the seed in URL, so you can randomize by pressing Refresh (F5) +- `template=star` - set board template (shape in which the marbles are arranged). + See the source code for a list of templates. The currently used template is also printed + to the debug console for convenience. Can be combined with 'rnd' or 'seed'. + + Note that some templates are hard to fill, so the engine can give up and switch to + a different random template. + +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. + +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 +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 an 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. + +If you're curious about the inner workings, open dev tools and enable debug logging with the `debug=1` +GET parameter. + +[localStorage]: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage +[rulespic]: https://steamuserimages-a.akamaihd.net/ugc/913534190478688278/601AEF665F446DF75AF787D8E102B255F3E905A1/ +[ruleslink]: https://steamcommunity.com/sharedfiles/filedetails/?id=1243498813 +[om]: http://www.zachtronics.com/opus-magnum/ diff --git a/index.html b/index.html index daf4588..6ad75f5 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + @@ -9,6 +9,18 @@ Sigmar's Garden Online + + + + +
diff --git a/script.js b/script.js index 696ebc2..594327b 100644 --- a/script.js +++ b/script.js @@ -133,7 +133,7 @@ class Board { this.buildBackground(); - this.buildGUI(); + this.buildGui(); this.initAutoScaling(); } @@ -615,7 +615,7 @@ class Board { return this.grid[n]; } - buildGUI() { + buildGui() { const y0 = 0.05; const x0 = 0; const ysp = 0.75; @@ -623,24 +623,24 @@ class Board { 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'); + this.buttons.undo = this.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 Gen:', 'config'); + this.buttons.optSloppy = this.addButton(x0, cfgy0+ysp2*2, 'Sloppy Mode:', 'config'); } updateSettingsGUI(cfg) { - this.buttons.optFancy.textContent = 'Effects: '+((cfg.svgAnimations || cfg.svgBlur) ? 'On' : 'Off'); - this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.disabledEffect ? 'On' : 'Off'); - this.buttons.optSloppy.textContent = 'Sloppy Gen: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off'); + 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}`); + let button = Svg.fromXML(`${text}`); this.$root.appendChild(button); return button; } @@ -705,14 +705,13 @@ class SettingsStorage { constructor() { // this object is never overwritten, references are stable this.defaults = { - log: 'info', version: 1, + log: 'info', allowTemplateAugmenting: false, retryTemplate: 30, attemptTemplates: 50, - svgAnimations: false, - svgBlur: false, - disabledEffect: true, + svgEffects: false, + dimBlocked: true, }; this.settings = Object.assign({}, this.defaults); } @@ -830,13 +829,13 @@ class Game { // Debug can be toggled via the debug=0/1 GET arg if (args.debug !== null) { - this.setCfg({log: (!!+args.debug) ? 'debug' : 'info'}); + this.setCfg({ log: (!!+args.debug) ? 'debug' : 'info' }); } if (args.trace !== null) { - this.setCfg({log: (!!+args.trace) ? 'trace' : 'debug'}); + this.setCfg({ log: (!!+args.trace) ? 'trace' : 'debug' }); } if (args.log !== null) { - this.setCfg({log: args.log}); + this.setCfg({ log: args.log }); } if (args.rnd !== null) { this.get_opts.url_seed = !!!+args.rnd; @@ -853,11 +852,7 @@ class Game { // Toggle GPU intensive effects via the pretty=0/1 GET arg if (args.pretty !== null) { - let pretty = !!+args.pretty; - this.setCfg({ - svgAnimations: pretty, - svgBlur: pretty, - }); + this.setCfg({ svgEffects: !!+args.pretty }); } this.info("Game settings:", this.cfg); @@ -929,9 +924,9 @@ class Game { } applySettings() { - this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.svgAnimations); - this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.disabledEffect); - this.board.$svg.classList.toggle('cfg-no-blur', !this.cfg.svgBlur); + this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.svgEffects); + this.board.$svg.classList.toggle('cfg-no-blur', !this.cfg.svgEffects); + this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.dimBlocked); this.applyLogFilter(); this.board.updateSettingsGUI(this.cfg); } @@ -1448,6 +1443,29 @@ class Game { 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.board.placeOrbByIndex(entry.n, entry.symbol); + } + + this.updateGameGui(); + } + /** * Handle orb click * @@ -1467,6 +1485,12 @@ class Game { if (orb.symbol === 'gold') { // gold has no pairing this.debug(`Removing gold.`); + + this.addUndoRecord([{ + symbol: orb.symbol, + n: orb.n, + }]); + this.board.removeOrbByIndex(n); this.selectedOrb = null; wantRefresh = true; @@ -1490,6 +1514,17 @@ class Game { 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, + } + ]); + // compatible pair clicked this.board.removeOrbByIndex(n); this.board.removeOrbByIndex(this.selectedOrb.n); @@ -1518,7 +1553,7 @@ class Game { this.info("Good work!"); } - this.updateGameGUI(); + this.updateGameGui(); } } @@ -1534,17 +1569,21 @@ class Game { this.newGame(+new Date); }); + this.board.buttons.undo.addEventListener('click', () => { + if (this.undoStack.length) { + this.undo(); + } + }); + this.board.buttons.optFancy.addEventListener('click', () => { - let val = !(this.cfg.svgAnimations || this.cfg.svgBlur); this.setCfg({ - svgAnimations: val, - svgBlur: val, + svgEffects: !this.cfg.svgEffects, }) }); this.board.buttons.optBlockedEffect.addEventListener('click', () => { this.setCfg({ - disabledEffect: !this.cfg.disabledEffect, + dimBlocked: !this.cfg.dimBlocked, }) }); @@ -1558,8 +1597,12 @@ class Game { /** * Update button hiding attributes, disabled orb effects, etc */ - updateGameGUI() { - this.board.buttons.restart.classList.toggle('disabled', this.countOrbs() === 55); + updateGameGui() { + this.board.buttons.restart + .classList.toggle('disabled', this.countOrbs() === 55); + + this.board.buttons.undo + .classList.toggle('disabled', this.undoStack.length === 0); // Update orb disabled status for (let n = 0; n < BOARD_SIZE; n++) { @@ -1590,6 +1633,7 @@ class Game { this.selectedOrb = null; this.nextMetal = 'lead'; + this.undoStack = []; let self = this; this.board.onOrbClick = (n, orb) => self.inGameBoardClick(n, orb); @@ -1632,7 +1676,7 @@ class Game { } this.renderPreparedBoard(); - this.updateGameGUI(); + this.updateGameGui(); if (!suc) { alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`);