Welcome back to my blog series on implementing procedurally-generated map compasses! Last time I completed the implementation of the Lodestone rules engine that is able to read an execute rules like these:
But entering these rules as a Javascript string is painful, so today I'm going to start off by building a little utility that will read in a file of rules and prepare them for use.
So the first complication is that we have to get to the rules file through the web server rather than loading it directly. The second complication is that the browser mechanism for fetching a file this way works asynchronously, meaning it doesn't wait around for the file to be fetched, it just promises to let you know later when it's done. There are actually two asynchronous steps to this process. Let me start with the first and easiest step -- fetching the contents of the URL as a “blob."
But since fetch is an asynchronous function, it returns immediately, before it has fetched anything and before the then() has pulled out the blob. The purpose of the await keyword in front of the fetch is to force the Javascript engine to stop and wait for the fetch (really the then) to complete. So in effect, the await keyword turns the asynchronous function into a synchronous function.
Now I need to turn the blob into text. Javascript provides a mechanism to do this called a FileReader, which has a method readAsText that takes a blob and returns text. Or at least it eventually returns text, because like fetch, FileReader is asynchronous. You might think then that I could use await like this:
And at the end of that, text holds the text of the compass.rules file. I can put these two awaits together and put this at the top level of the compass module as so:
When you put an await at the top level of a module like this, you essentially pause the loading of the module until the await completes. In this case, that's fine, but in many cases you want to avoid doing that so that you can immediately use the parts of the module that aren't waiting.
Now that I have the text of the compass.rules file, I can prepare those rules and then execute them to create a compass description in CDL and pass it to the interpreter:
Of course, I'll need to actually have some rules in compass.rules that will produce a compass :-) so let me start in on that. (Finally! We've been waiting!) In the past posts I've used these two compasses as examples:
These are what I think of as “two-layer" compasses. They've got a bottom layer consisting of a circular decoration, and then a top layer of compass points. There are a few other two-layer compasses in the example compasses, but I'll start with these two.
To begin with, I'll put together rules to produce the left-hand example.
<compass> => <twoLayerCompass>;
<twoLayerCompass> => <bottomLayer> <topLayer>;
<bottomLayer> => SPACE(23) <ring>;
<ring> => <thickCircle> SPACE(3) <thinCircle>;
<thickCircle> => CIRCLE(<$thickWidth>, "black", "none");
<thinCircle> => CIRCLE(<$thinWidth>, "black", "none");
<$thickWidth> => 4 | 3.5 | 3;
<$thinWidth> => 1.5 | 1 | 0.5;
<topLayer> => <interCardinalPoints> <cardinalPoints>;
<interCardinalPoints> => RPOINT(0.7854, 4, 0.85, 1, 1, "black", "white", "black");
<cardinalPoints> => SPACE(-22) RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");
This reproduces something fairly close to the example compass above, but that's all it can do. It's stuck at
Level 0 of procedural generation, only able to produce one object. But by individually generalizing the parameters in these rules I can transform this into a Level 1 procedural generator.
Let's start with a single rule:
<bottomLayer> => SPACE(23) <ring>;
How can I generalize this rule to increase the space of compasses it produces? The obvious first candidate is to turn the specific 23 pixel space into a range of possible spaces. It isn't immediately obvious what a reasonable range is, but I can write the rule and try it out to narrow down. To pick a random range of values I could write a rule like this:
distance => 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27;
But those sorts of rules get tedious very quickly, so I prefer to use the embedded Javascript capability to have Javascript generate a random number in a range:
<bottomLayer> => SPACE(`Math.random()*40+10`) <ring>;
In this case in the range from 10 to 50. It's handy to have some utility functions to deal with random numbers, so I've included some in utils.js and I'll use one here instead of Math.random:
<bottomLayer> => SPACE(`Utils.randIntRange(10,50)`) <ring>;
This function generates an random integer in the given range. But rather than moving the ring in closer or farther away from the center, this seems to grow or shrink the whole compass:
So what's going on here? The problem is not in this rule but in a related rule:
<cardinalPoints> => SPACE(-22) RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");
The SPACE(-22) here is intended to undo the SPACE(23) in the <bottomLayer> rule. But when the value in that rule isn't 23, everything breaks.
There are a number of ways you might fix this. But I think what's really needed here is a method in CDL to remember a location and return to it later. I implemented a version of this for
MBDL called PUSH and POP that let you return to the last location you had remembered. But for CDL I think it will be better to have the capability to remember and return to multiple locations by name, so I'm going to implement two new commands. REMEMBER(name) will remember the current location as “name" and RECALL(name) will return to that location.
Implementing this is not difficult. First I will add those commands to the Nearley parser:
# Remember a location
rememberElement -> "REMEMBER"i WS "(" WS dqstring WS ")" WS
{% data => ({op: "REMEMBER", name: data[4]}) %}
# Recall a location
recallElement -> "RECALL"i WS "(" WS dqstring WS ")" WS
{% data => ({op: "RECALL", name: data[4]}) %}
If you remember your
Nearley this is pretty straightforward.
Then I'll implement the two new operations in interpretCDL in compass.js:
const memory = {};
} else if (op.op == 'REMEMBER') {
memory[op.name] = radius;
} else if (op.op == 'RECALL') {
radius = memory[op.name];
}
memory is a Javascript dictionary that will keep track of the remembered locations. To REMEMBER a location, we set the name of the location in the dictionary to the current value of the radius. (Which is where we're drawing.) To RECALL a location, we get the remembered location out of the dictionary and set radius to that value.
With that in place, I can now modify the compass rules as so:
<twoLayerCompass> => REMEMBER("start") <bottomLayer> <topLayer>;
<bottomLayer> => SPACE(`Utils.randIntRange(10,35)`) <ring> REMEMBER("insideEdge");
<interCardinalPoints> => RECALL("insideEdge") RPOINT(0.7854, 4, 0.85, 1, 1, "black", "white", "black");
<cardinalPoints> => RECALL("start") RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");
I have to remember and return to two locations. The first is the start location, so I can go back there to draw the cardinal (N/S/E/W) points. I also need to remember where the inside edge of the bottom layer is, because I want the intercardinal (NE/SE/SW/NW) points to just reach the inside edge.
And now even when the bottom layer ring is far out, or far in towards the center, the cardinal points still reach to the outside of the compass:
So now I can play around a bit and pick a range for this parameter. 10 to 30 seems to be reasonable to me, but you can make your own decision. This process of selecting independent random ranges for the parameters of the individual elements I called Level 1 procedural generation in
a previous posting.
Now that I'm repeatedly generating compasses to check the range on parameters, I'm running into a problem that I noted in the Suggestions to Explore last time -- if you hit the Test button more than once, the new compass gets drawn on top of the old compass:
Which
can look interesting sometimes but does make it difficult to see what is going on. To fix this, before we draw a new compass we need to remove the old compass. Remember that the compass on the screen is just a number of SVG commands within the SVG element on the web page, so what we need to do is delete all the commands within that SVG element. In D3, this is easy:
function test(svg) {
// Remove any previous compass
svg.selectAll('*').remove();
const rules = prepareLodestone(compassRulesText);
const cdl = executeRules('<compass>', rules, s => eval(s));
const result = interpretCDL(svg, parseCDL(cdl, true), [100,100], 75, true);
};
selectAll is a D3 function that lets us act on all the elements that match an expression. In this case, the wildcard '*' matches all the elements within the SVG element, and then the remove() method removes those elements. This has the effect of clearing the SVG before the new compass is drawn.
Another parameter I can vary is the width of the compass points. Some experimentation suggests that a range of 0.80 to 0.925 works well. I also already have rules for the sizes of thick and thin lines, so that's about the range of Level 1 generation for this example.
Level 2 of procedural generation is multi-part constraints. There are some of these already built into this compass -- namely that the intercardinal points are constrained to start at the inner edge of the ring. That's certainly something we could experiment with.
That looks okay to me. A negative spacing from the inside ring is interesting if it happens to hit the thick ring:
We don't have external constraints (Level 3) yet, so let's briefly move on to Level 4, independent subtypes. The obvious subtypes with these compasses are the design of the ring. Last time I did some ring subtypes to illustrate RiTa rules, so I can resurrect those here to add thin-thick and the other variants.
<ring> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin>;
<thickThin> => <thickCircle> SPACE(3) <thinCircle>;
<thinThick> => <thinCircle> SPACE(3) <thickCircle>;
<thinThinThin> => <thinCircle> SPACE(3) <thinCircle> SPACE(3) <thinCircle>;
<thinThickThin> => <thinCircle> SPACE(3) <thickCircle> SPACE(3) <thinCircle>;
When I added these rules I realized that I'd missed a Level 1 opportunity -- the space between the circles could be varied. I find this pretty common -- I'll work back and forth across the different levels of procedural generation, and making a change at one level will cause me to go back and add related changes at different levels.
With those rules in place I'm now generating a wider variety of compasses, although they're all still obviously in the same family:
As you add rules and complexity to the procedural generation and generate examples, you'll likely come across some results that you don't like.
When this happens, I like to do a little analysis to understand why I don't like the result. (Interestingly, in coming back to review this post weeks later, I think this compass looks fine. There's a lesson there!) In this case, I think the intercardinal pointers look wrong. Why is that? Perhaps because they're too narrow, which would direct me towards reconsidering the Level 1 constraint on that part of the generation. Or maybe they're only too narrow in comparison to the fairly thick cardinal pointers. That would suggest some kind of Level 2 constraint between those two parts of the compass.
I also think about whether or not I should simply accept this compass, even if I don't like it a lot. There's a trade-off between getting a wide variety of interesting results from procedural generation and accepting some poor results. If you try to “clamp down" too much to avoid unhappy results you end up with something that can generate only in a limited area of the creative space and that's usually pretty boring (if competent). So it might be better to accept some level of bad results, particularly if you can toss those out and just use the good results. On the other hand, this can be a trap if you're building something like
Dragons Abound with many different procedural generation systems. If each system has only a 1% chance of getting a bad result, but you have 100 systems, then your chance of getting all 100 good results is only about 40% (!). (The power of combinatorics.) So you'll be throwing out a lot of bad results.
Let me finish off this compass by adding labels to the cardinal points. To do this I'll draw the labels first and then draw the compass inside the labels.
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<labels> => RTEXT(0, 4, "Serif", 16, "black", "", "bolder", "vertical", '["N", "E", "S", "W"]') SPACE(8);
(Note that I have to put the array of labels in single quotes. Otherwise Lodestone is going to interpret them as a weight -- Lodestone expects anything between square brackets to be a rule weight.)
The labels are an example of where we might get a Level 3 external constraint in procedural compass generation. Rather than always use the same font, or pick a font randomly, we might want to use a font that matches (or complements) the fonts we're using in the rest of the map. But for now I don't have that constraint, so I'll modify this to add some Level 1 variation in the font, size, and type of the labels.
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<$labelFont> => "Serif" | "Lobster" | "IM Fell English";
<$labelSize> => 14 | 16 | 18;
<$labelStyle> => "normal" | "bold" | "bolder";
<labels> => RTEXT(0, 4, <$labelFont>, <$labelSize>, "black", "", <$labelStyle>, "vertical", '["N", "E", "S", "W"]') SPACE(8);
I've added a couple of new font options here: Lobster and IM Fell English. These are not fonts that the browser knows about by default, so I need to add some code to the test.html web page to load these fonts. These are
Google Fonts, meaning I can load them off of a Google server with this incantation that I've added near the top of test.html:
<link href='https://fonts.googleapis.com/css?family=Lobster' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=IM+Fell+English' rel='stylesheet' type='text/css'>
(This is a partial answer to one of the Suggestions to Explore in
Part 8.)
Now I can now use those fonts in the labels:
The complete rules for this compass now look like this:
<compass> => <twoLayerCompass>;
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<$labelFont> => "Serif" | "Lobster" | "IM Fell English";
<$labelSize> => 14 | 16 | 18;
<$labelStyle> => "normal" | "bold" | "bolder";
<labels> => RTEXT(0, 4, <$labelFont>, <$labelSize>, "black", "", <$labelStyle>, "vertical", '["N", "E", "S", "W"]') SPACE(8);
<bottomLayer> => SPACE(`Utils.randIntRange(10,30)`) <ring>
REMEMBER("insideEdge");
<ring> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin>;
<$ringSpacing> => 1 | 2 | 2 | 3 | 3 | 4;
<thickThin> => <thickCircle> SPACE(<$ringSpacing>) <thinCircle>;
<thinThick> => <thinCircle> SPACE(<$ringSpacing>) <thickCircle>;
<thinThinThin> => <thinCircle> SPACE(<$ringSpacing>) <thinCircle>
SPACE(<$ringSpacing>) <thinCircle>;
<thinThickThin> => <thinCircle> SPACE(<$ringSpacing>) <thickCircle>
SPACE(<$ringSpacing>) <thinCircle>;
<$thickWidth> => 4 | 3.5 | 3;
<$thinWidth> => 1.5 | 1 | 0.5;
<thickCircle> => CIRCLE(<$thickWidth>, "black", "none");
<thinCircle> => CIRCLE(<$thinWidth>, "black", "none");
<topLayer> => <interCardinalPoints> <cardinalPoints>;
<interCardinalPoints> => RECALL("insideEdge")
SPACE(`Utils.randIntRange(-5,5)`)
RPOINT(0.7854, 4, `Utils.randRange(0.80,0.925)`,
1, 1, "black", "white", "black");
<cardinalPoints> => RECALL("start")
RPOINT(0, 4, `Utils.randRange(0.80,0.925)`, 1, 1,
"black", "white", "black");
What's striking about this is that I already have 20 rules for generation, just for this one type of compass! Of course, some of these rules will likely be reused in other compass types, but in general I find that this sort of grammar-based procedural generation leads to many rules, and organizing and keeping track of them can sometimes be a challenge.
A reminder that if you want to follow along from home, you need to download the Part 14 branch from the Procedural Map Compasses repository on Github and get the test web page up and open the console. Part 1 has instructions, but the short version (on Windows) is to double-click mongoose-free-6.9.exe to start up a web server in the Part 14 directory, and then select the test.html link. You'll find the rules for the compass above in the compass.js file and you can experiment with changing and extending the rules. You can also try out this code on the web at
https://dragonsabound-part14.netlify.app/test.html although you'll only be able to run the code, not modify it.
Suggestions to Explore- Level 4 procedural generation is independent subtypes. A rule of this sort for the compass points would be to have wavy compass points for the intercardinal compass points. Implement this rule.
- There are many more subtypes to explore for the <ring> in this compass style. Implement the scale from the other example at the top of this posting. Implement some rings that use radial circle, triangle or diamond elements.
- Suppose you want to implement a meta-subtype for <ring> which would repeat the same ring twice:
In general, is there any way to write rules which will repeat some previous rules? If not, how would you add that capability?
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.