add readme and some fixing

master
Ondřej Hruška 5 years ago
parent 6cea780500
commit 0311126369
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 127
      README.md
  2. 14
      index.html
  3. 98
      script.js

@ -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/

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" dir="ltr"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@ -9,6 +9,18 @@
<title>Sigmar's Garden Online</title> <title>Sigmar's Garden Online</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<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.">
<!--
Based on the Sigmar's Garden minigame in Zachtronics' Opus Magnum
-> http://www.zachtronics.com/opus-magnum/
Code, Readme & issue tracker: https://git.ondrovo.com/MightyPork/sigmar
Live demo: https://bits.ondrovo.com/sigmar
Contact: ondra@ondrovo.com
-->
</head> </head>
<body> <body>
<div id="wrap"> <div id="wrap">

@ -133,7 +133,7 @@ class Board {
this.buildBackground(); this.buildBackground();
this.buildGUI(); this.buildGui();
this.initAutoScaling(); this.initAutoScaling();
} }
@ -615,7 +615,7 @@ class Board {
return this.grid[n]; return this.grid[n];
} }
buildGUI() { buildGui() {
const y0 = 0.05; const y0 = 0.05;
const x0 = 0; const x0 = 0;
const ysp = 0.75; const ysp = 0.75;
@ -623,19 +623,19 @@ class Board {
this.buttons.randomize = this.addButton(x0, y0, 'Randomize'); this.buttons.randomize = this.addButton(x0, y0, 'Randomize');
this.buttons.restart = this.addButton(x0, y0 + ysp, 'Try Again', 'disabled'); 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; const cfgy0 = 10;
this.buttons.optFancy = this.addButton(x0, cfgy0, 'Effects:', 'config'); this.buttons.optFancy = this.addButton(x0, cfgy0, 'Effects:', 'config');
this.buttons.optBlockedEffect = this.addButton(x0, cfgy0+ysp2, 'Dim Blocked:', '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) { updateSettingsGUI(cfg) {
this.buttons.optFancy.textContent = 'Effects: '+((cfg.svgAnimations || cfg.svgBlur) ? 'On' : 'Off'); this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off');
this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.disabledEffect ? 'On' : 'Off'); this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? 'On' : 'Off');
this.buttons.optSloppy.textContent = 'Sloppy Gen: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off'); this.buttons.optSloppy.textContent = 'Sloppy Mode: '+(cfg.allowTemplateAugmenting ? 'On' : 'Off');
} }
addButton(x, y, text, classes='') { addButton(x, y, text, classes='') {
@ -705,14 +705,13 @@ class SettingsStorage {
constructor() { constructor() {
// this object is never overwritten, references are stable // this object is never overwritten, references are stable
this.defaults = { this.defaults = {
log: 'info',
version: 1, version: 1,
log: 'info',
allowTemplateAugmenting: false, allowTemplateAugmenting: false,
retryTemplate: 30, retryTemplate: 30,
attemptTemplates: 50, attemptTemplates: 50,
svgAnimations: false, svgEffects: false,
svgBlur: false, dimBlocked: true,
disabledEffect: true,
}; };
this.settings = Object.assign({}, this.defaults); this.settings = Object.assign({}, this.defaults);
} }
@ -853,11 +852,7 @@ class Game {
// Toggle GPU intensive effects via the pretty=0/1 GET arg // Toggle GPU intensive effects via the pretty=0/1 GET arg
if (args.pretty !== null) { if (args.pretty !== null) {
let pretty = !!+args.pretty; this.setCfg({ svgEffects: !!+args.pretty });
this.setCfg({
svgAnimations: pretty,
svgBlur: pretty,
});
} }
this.info("Game settings:", this.cfg); this.info("Game settings:", this.cfg);
@ -929,9 +924,9 @@ class Game {
} }
applySettings() { applySettings() {
this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.svgAnimations); this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.svgEffects);
this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.disabledEffect); this.board.$svg.classList.toggle('cfg-no-blur', !this.cfg.svgEffects);
this.board.$svg.classList.toggle('cfg-no-blur', !this.cfg.svgBlur); this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.dimBlocked);
this.applyLogFilter(); this.applyLogFilter();
this.board.updateSettingsGUI(this.cfg); this.board.updateSettingsGUI(this.cfg);
} }
@ -1448,6 +1443,29 @@ class Game {
return ava; 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 * Handle orb click
* *
@ -1467,6 +1485,12 @@ class Game {
if (orb.symbol === 'gold') { if (orb.symbol === 'gold') {
// gold has no pairing // gold has no pairing
this.debug(`Removing gold.`); this.debug(`Removing gold.`);
this.addUndoRecord([{
symbol: orb.symbol,
n: orb.n,
}]);
this.board.removeOrbByIndex(n); this.board.removeOrbByIndex(n);
this.selectedOrb = null; this.selectedOrb = null;
wantRefresh = true; wantRefresh = true;
@ -1490,6 +1514,17 @@ class Game {
if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) { if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) {
this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`); 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 // compatible pair clicked
this.board.removeOrbByIndex(n); this.board.removeOrbByIndex(n);
this.board.removeOrbByIndex(this.selectedOrb.n); this.board.removeOrbByIndex(this.selectedOrb.n);
@ -1518,7 +1553,7 @@ class Game {
this.info("Good work!"); this.info("Good work!");
} }
this.updateGameGUI(); this.updateGameGui();
} }
} }
@ -1534,17 +1569,21 @@ class Game {
this.newGame(+new Date); this.newGame(+new Date);
}); });
this.board.buttons.undo.addEventListener('click', () => {
if (this.undoStack.length) {
this.undo();
}
});
this.board.buttons.optFancy.addEventListener('click', () => { this.board.buttons.optFancy.addEventListener('click', () => {
let val = !(this.cfg.svgAnimations || this.cfg.svgBlur);
this.setCfg({ this.setCfg({
svgAnimations: val, svgEffects: !this.cfg.svgEffects,
svgBlur: val,
}) })
}); });
this.board.buttons.optBlockedEffect.addEventListener('click', () => { this.board.buttons.optBlockedEffect.addEventListener('click', () => {
this.setCfg({ this.setCfg({
disabledEffect: !this.cfg.disabledEffect, dimBlocked: !this.cfg.dimBlocked,
}) })
}); });
@ -1558,8 +1597,12 @@ class Game {
/** /**
* Update button hiding attributes, disabled orb effects, etc * Update button hiding attributes, disabled orb effects, etc
*/ */
updateGameGUI() { updateGameGui() {
this.board.buttons.restart.classList.toggle('disabled', this.countOrbs() === 55); 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 // Update orb disabled status
for (let n = 0; n < BOARD_SIZE; n++) { for (let n = 0; n < BOARD_SIZE; n++) {
@ -1590,6 +1633,7 @@ class Game {
this.selectedOrb = null; this.selectedOrb = null;
this.nextMetal = 'lead'; this.nextMetal = 'lead';
this.undoStack = [];
let self = this; let self = this;
this.board.onOrbClick = (n, orb) => self.inGameBoardClick(n, orb); this.board.onOrbClick = (n, orb) => self.inGameBoardClick(n, orb);
@ -1632,7 +1676,7 @@ class Game {
} }
this.renderPreparedBoard(); this.renderPreparedBoard();
this.updateGameGUI(); this.updateGameGui();
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.`);

Loading…
Cancel
Save