Welcome back to my blog series on implementing procedurally-generated map compasses! For the past few posts I've worked on elaborating the “rings" that appear behind the compass points in compasses like these:
There isn't nearly as much variation in compass points as in rings. One common variation is to turn the compass points into some sort of arrowhead or other type of artistic pointer, as in this example:
I'm not going to try to replicate something like this. It's certainly possible to draw arrowhead like compass points, but I'm not sure I can do that with much variety and more importantly, I don't particularly like that style. If you love it, please have a go and let me know what you come up with.
Sticking to the traditional style of compass point, the main variations are straight or wavy, and the number of points used. I covered how to draw wavy compass points back in Part 10, so I won't go over that again. As far as the number of points go, so far the procedural generation rules always generate eight points. So let's see about varying that.
To start with, I can modify the rule to create compasses which only have points for the four cardinal directions:
<topLayer> => <cardinalPoints> | <interCardinalPoints> <cardinalPoints>;
In the other direction, I can add the interordinal points. These are eight points that fall between the intercardinal (also called ordinal) points.
<topLayer> => <cardinalPoints> | <interCardinalPoints> <cardinalPoints> | <interOrdinalPoints> <interCardinalPoints> <cardinalPoints> [100]; <interOrdinalPoints> => RECALL("insideEdge") SPACE(`Utils.randIntRange(7,12)`) <interOrdinalPoint>; <interOrdinalPoint> => RPOINT(0.39269908, 8, `Utils.randRange(0.80,0.925)`, 1, 1, "black", "white", "black") | RWAVE(0.39269908, 8, `Utils.randRange(0.80,0.925)`, 1, 1, "black", "white", "black");
The interordinal points are also measured from the inside edge of the first ring, but they're 7-12 pixels closer to the center so that they'll never be longer than the intercardinal points. The interordinal points start at PI/8 (i.e., 0.39269908), which places the first one between N and NE.
Because there are 8 of these points instead of 4, if I use the span value as for the other points, these points will be half as wide. That's fine; the finer divisions are usually drawn with finer points.
One question is whether the interordinal points should be wavy when the intercardinal points are wavy. To my eye, it seems fine to have straight points mixed with wavy points:
One problem I'm noticing as I generate examples are compasses like this:That's at least partly because there isn't enough visual separation between the two rings to make the points stand out. I can adjust the ring spacing to help with this.
That works much better, I think. But problems still remain when the inner ring has radial elements:
Since the problem arises from the overlapping of the points and the radial elements, one solution might be to shift the elements so that they are between the points:That seems like it will work, but there are some complications. This works for a number of radial elements that is a multiple of the number of gaps between the compass points. When there are two levels of compass points, the number of gaps is 8, so 8, 16, 24, 32, etc. radial elements will work. But when there are three levels of compass points, the number of gaps is 16, so this will work with 16, 32, 48, etc. But it won't work with 8 or 24 because the elements will fall in strange places:There's no offset that results in a symmetrical arrangement. So this will require replicating all the radial element rules once for each case. Worse, this approach doesn't make sense for an outer ring of radial elements. The whole point of this is to move the radial elements off of the compass points, but in the outer ring that's desirable:so there will need to be another set of rules for the outer ring. Rules tend to proliferate in these sorts of systems, but this seems a bit excessive.That works much better, I think. But problems still remain when the inner ring has radial elements:
What I need is way to remember a decision that was made in one rule (e.g., to show intercardinal compass points) and then use that decision in a later rule (e.g., to select the right number and offset for radial elements). Right now Lodestone doesn't have that capability, so I'll have to add it.
The way I'll do this is to combine the embedded Javascript capability and the one-time rules along with some modifications to the rules engine. In the end, this will let me write rules like this:
<rule1> => <first> `<@tmp>=6` <second>; [...] <rule3> => "The value is: " <$tmp>;The @ syntax lets me assign a value to a one-time rule within the embedded Javascript, and then I can use the new value later just as I would any one-time rule.
To start, I'll modify the Loadstone grammar to accept these kind of references. That starts with adding a new class to the lexer:
// // One-time non-terminal reference in the form <@test> // otntermref: /<\@[^>]+>/,This looks exactly like a one-time non-terminal, except it expects an @ instead of a $. The second part is to allow these references to show up on the right-hand side of rules, which I can accomplish by adding them to the list of legal elements:
# # An element can be a non-terminal (e.g., <test>), a one-time # non-terminal (e.g., <$test>), a reference to a one-time # non-terminal (e.g., <@test>), a string of characters (e.g., SPACE), # a quoted string of characters (e.g., 'this here'), embedded # Javascript (e.g., `Math.PI/2`), a weight (e.g., [50]), or # the character for an alternative (e.g., |) # element -> %nterm | %otnterm | %otntermref | %string | %qstring | %jscript | %weight | %orIn truth, I only want them to appear inside embedded Javascript, and this will allow them to appear anywhere on the right-hand side, but I'll live with that for the moment.
Now I need to modify the Lodestone rules engine so that it knows how to deal with these references. That's done by adding this code to executeRules:
// If it's a reference to a one-time terminal, then replace the // reference with the Javascript to reference where the value // of the one-time terminal is kept. if (current.type == "otntermref") { output = output.concat('otValues["'+current.value.replace('@','$')+'"]'); continue; };What this does is replace a reference like “<@test>" with “otValues[''<$test>'']". The latter is the Javascript code for where the values of one-time non-terminals are stored. So now if I write embedded Javascript like this:
`<@test> = 15`it will be translated into
`otValues["<$test>"] = 15`before it is executed as Javascript. So 15 will get stored into <$test> -- or at least it will if otValues evaluates to the proper object when the Javascript is executed.
To ensure this is a little tricky. I want embedded Javascript to execute as if it were executing in the context (e.g., closure) of the function from where executeRules is called. This allows the user to do something like this to define a value:
let defaultWidth = 1;And then reference that in the embedded Javacript in a rule. But otValues isn't in this context, so as it stands this reference will fail. To address this, I have to add otValues to the context for the embedded Javascript. That context is defined by the function the user provides, e.g.,
const cdl = executeRules('<compass>', rules, s => eval(s));In this case, the last argument that defines a function that calls eval. To add otValues to this context, I'll add another parameter to the context function which (critically!) I will also call otValues:
const cdl = executeRules('<compass>', rules, (s, otValues) => eval(s));(In fact, I could have called this parameter anything, as long as I used the same name in the @ translation above. But it's convenient to use the same name consistently.) The last step is to provide the real otValues as the value of that parameter. This happens when this function gets called in executeRules:
// Execute in the provided context. const execVal = String(context(expanded, otValues));Now the real otValues gets matched up with the parameter otValues and is available to eval when a one-time non-terminal references is used! (Whew! I know that's a little difficult to follow, but it's a complex idea and I've done the best I can.)
One trickiness remains. When you use embedded Javascript, the return value gets substituted back into the rule where the embedded Javascript was found. In a case like this where we're using the Javascript for a side effect such as setting a value:
<rule1> => <first> `<@tmp>=6` <second>;we probably won't want the value of the Javascript (6 in this case) to be inserted into the rule. To avoid this, I need to modify the Javascript to return an empty string by adding a second statement that will evaluate to the empty string, i.e.,
<rule1> => <first> `@tmp=6; ""` <second>; [...] <rule3> => "The value is: " $tmp;(You can also use the Javascript comma operator here.) This is a little clumsy, but certainly workable.
Now let's put this to work for our radial elements. Recall from above that different numbers of radial elements work for the inside rings depending upon whether or not the compass has two levels of pointers or three levels. The number of radial elements is controlled by $numRadialElements, so I can set then when the choice for levels of compass points is made:
<topLayer> => <cardinalPoints> `@numRadialElements=Utils.randElement([4,8,12,16,20,24,28,32]),""` | <interCardinalPoints> <cardinalPoints> `@numRadialElements=Utils.randElement([8,16,24,32]),""` | <interOrdinalPoints> <interCardinalPoints> <cardinalPoints> `@numRadialElements=Utils.randElement([16,32]),""`;Remember that inside the backticks is Javascript, so I'm using a utility function that picks a random element of an array to make the choice of how many radial elements to use.
Sadly, this isn't quite right. The problem is that the bottom layer gets created first:<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;which means the radial elements have already been created before the <topLayer> rule decides how many points there will be. I need to modify the rules to choose the level of compass points before the bottom layer is drawn. That's easy enough to do with a one-time rule:
$pointLevels => 1 | 2 | 3;But how will I use that to control the <topLayer> rule that actually draws the compass points? The trick is to use the choice weightings.
Recall that the choice weightings are numbers in square brackets that control how often a choice is selected in a rule. For example this rule:
<ringOrScale> => <ring> [5] | <scaleRing>;uses choice weightings so that rings are selected 5x as frequently as scales. Two features of choice weighting are useful to us here. First, choice weightings can use one-time rules or embedded Javascript to determine the weighting. Second, a weighting of zero means a choice will never be chosen. So to force a particular number of points to be drawn, I need to set the weights of the other choices to zero. For example, this rule:
<topLayer> => <cardinalPoints> [0] | <interCardinalPoints> <cardinalPoints> [1] | <interOrdinalPoints> <interCardinalPoints> <cardinalPoints> [0]will always draw two levels of points, since the other two options are set to zero. Now I need to modify this rule so that a weight of zero or one is selected for each option based upon $pointLevels:
<topLayer> => <cardinalPoints> [`$pointLevels == 1 ? 1 : 0`] | <interCardinalPoints> <cardinalPoints> [`$pointLevels == 2 ? 1 : 0`] | <interOrdinalPoints> <interCardinalPoints> <cardinalPoints> [`$pointLevels == 3 ? 1 : 0`];The embedded Javascript uses the ternary operator to check if $pointLevels is set to the corresponding level, and returns 1 if so and otherwise 0. So the weight for the proper choice ends up being 1 and the other choices zero.
I also need to set up the proper number of radial elements based upon the points level. I'll do that at the same time I pick the point level:
$pointLevels => 1 `@numInsideRadialElements=Utils.randElement([4,8,12,16,20,24,28,32]),""` | 2 `@numInsideRadialElements=Utils.randElement([8,16,24,32]),""` | 3 `@numInsideRadialElements=Utils.randElement([16,32]),""` ;I also need to set some default values for the number of radial elements and the offset of the elements. These are the values that will be used in any outer ring of radial elements, where we don't have to worry about clashing with the compass points:
$numRadialElements => 8 | 12 | 16 | 20 | 24 | 28 | 32; $offsetRadialElements => 0;The last part of the puzzle is to replace these default values before I create any inner ring of radial elements.
<bottomLayer> => SPACE(`Utils.randIntRange(10,30)`) <ringOrScale> REMEMBER("insideEdge") | SPACE(`Utils.randIntRange(5,10)`) <ringOrScale> REMEMBER("insideEdge") `@numRadialElements = $numInsideRadialElements, ""` `@offsetRadialElements = Math.PI/$numInsideRadialElements, ""` SPACE(10) <ring>;This is the rule that decides whether the bottom layer will have only an outer ring (the first choice) or an outer and an inner ring (the second choice). In the second choice, the embedded Javascript sets the number of radial elements to the value that was selected back in the $pointLevels rule, and then uses that to calculate an offset.
Here's an example where there are 20 elements in the outside ring with no offset and eight elements in the inside ring with an offset that centers the first element between the first two compass points.
I'm still not entirely happy with how compasses like the above example look where a point crosses a dark background element. One way to address this might be to add a white border around the point to create a visual break with the background. Here's an example of what this would look like:
If you examine where the points cross background objects you'll see there's now a thin white line separating the point. This is done by drawing the outline of the point in white, using a thicker line, before drawing the point on top. The result is a white line around the point. However, if you draw this all the way around the point you get some unattractive artifacts where the points meet, as is evident in this example:
If you only draw to the widest parts of the point there is still a problem:
The white line for the top points separates them in an awkward way from the lower points. The solution is to pull the midpoints in slightly for the white line, so that it tapers down to nothing at the midpoints. This is easier to see if I draw the lines in red:
The separation is fat out at the tip of the point and diminishes to nothing at the midpoint. A similar technique can be applied to wavy points as well.
I don't think this is a “traditional" compass-drawing technique, and it will be problematic once more colors are possible, so I'll leave it in for now as an option. (But see below for controlling the option from CDL.)
A reminder that if you want to follow along from home, you need to download the Part 17 branch from the Procedural Map Compasses repository on Github and get the test web page open. Part 1 of this series has more detailed 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 17 directory, and then select the test.html link. You can also try out this code on the web at https://dragonsabound-part17.netlify.app/test.html although you'll only be able to run the code, not modify it.Suggestions to Explore
- I noted above that one-time non-terminal references should only be allowed within embedded Javascript. Can you modify the Lodestone grammar to enforce that?
- Add a command to CDL to control the white border option, and more generally for any other options that might arise. OPTION(name, ON/OFF/value) might be a suitable format.
You're getting an amazing variety of designs now. I really like the subtle white borders to the points in the last section.
ReplyDelete