File Coverage

blib/lib/Game/TextMapper/Folkesten.pm
Criterion Covered Total %
statement 198 269 73.6
branch 67 114 58.7
condition 37 45 82.2
subroutine 25 27 92.5
pod 15 17 88.2
total 342 472 72.4


line stmt bran cond sub pod time code
1             # Copyright (C) 2023 Alex Schroeder
2             #
3             # This program is free software: you can redistribute it and/or modify it under
4             # the terms of the GNU Affero General Public License as published by the Free
5             # Software Foundation, either version 3 of the License, or (at your option) any
6             # later version.
7             #
8             # This program is distributed in the hope that it will be useful, but WITHOUT
9             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10             # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
11             # details.
12             #
13             # You should have received a copy of the GNU Affero General Public License along
14             # with this program. If not, see .
15              
16             =encoding utf8
17              
18             =head1 NAME
19              
20             Game::TextMapper::Folkesten - generate fantasy wilderness maps
21              
22             =head1 SYNOPSIS
23              
24             my $text = Game::TextMapper::Folkesten->new
25             ->generate_map();
26              
27             =head1 DESCRIPTION
28              
29             This generates a wilderness map based on the algorithm by Andreas Folkesten. See the
30             blog posts at L.
31              
32             =head1 METHODS
33              
34             Note that this module acts as a class with the C method, but none
35             of the other subroutines defined are actual methods. They don't take a C<$self>
36             argument.
37              
38             =cut
39              
40             package Game::TextMapper::Folkesten;
41 11     11   91 use Game::TextMapper::Log;
  11         26  
  11         461  
42 11     11   63 use Game::TextMapper::Point;
  11         25  
  11         95  
43 11     11   386 use Modern::Perl '2018';
  11         28  
  11         79  
44 11     11   8212 use Mojo::Base -base;
  11         28  
  11         93  
45 11     11   2511 use List::Util qw(shuffle any first);
  11         72  
  11         96112  
46              
47             has 'world' => sub { {} };
48             has 'dry' => sub { {} };
49             has 'wet' => sub { {} };
50             has 'width' => 10;
51             has 'height' => 10;
52             has 'rivers' => sub { [] };
53             has 'canyons' => sub { [] };
54             has 'altitude' => sub {
55             {
56             'mountain' => 3,
57             'forest-hill' => 2,
58             'green-hills' => 2,
59             'hills' => 2,
60             'plain' => 1,
61             'water' => 0,
62             'ocean' => 0,
63             }
64             };
65              
66             *coord = \&Game::TextMapper::Point::coord;
67              
68             my $log = Game::TextMapper::Log->get;
69              
70             =head2 neighbors
71              
72             The list of directions for neighbours one step away (0 to 5).
73              
74             =cut
75              
76 281     281 1 1087 sub neighbors { 0 .. 5 }
77              
78             =head2 random_neighbor
79              
80             A random direction for a neighbour one step away (a random integer from 0 to 5).
81              
82             =cut
83              
84 0     0 1 0 sub random_neighbor { int(rand(6)) }
85              
86             =head2 neighbor($hex, $i)
87              
88             say join(",", $map->neighbor("0203", 1));
89             # 2,2
90              
91             Returns the coordinates of a neighbor in a particular direction (0 to 5), one
92             step away.
93              
94             C<$hex> is an array reference of coordinates or a string that can be turned into
95             one using the C method.
96              
97             C<$i> is a direction (0 to 5).
98              
99             =cut
100              
101             sub neighbor {
102 1686     1686 1 2701 my $self = shift;
103             # $hex is [x,y] or "0x0y" and $i is a number 0 .. 5
104 1686         3332 my ($hex, $i) = @_;
105 1686 50       4384 die join(":", caller) . ": undefined direction for $hex\n" unless defined $i;
106 1686 50       4719 $hex = [$self->xy($hex)] unless ref $hex;
107 1686         10025 my $delta_hex = [
108             # x is even
109             [[-1, 0], [ 0, -1], [+1, 0], [+1, +1], [ 0, +1], [-1, +1]],
110             # x is odd
111             [[-1, -1], [ 0, -1], [+1, -1], [+1, 0], [ 0, +1], [-1, 0]]];
112 1686         12390 return ($hex->[0] + $delta_hex->[$hex->[0] % 2]->[$i]->[0],
113             $hex->[1] + $delta_hex->[$hex->[0] % 2]->[$i]->[1]);
114             }
115              
116             =head2 xy($coordinates)
117              
118             C<$coordinates> is a string with four digites and interpreted as coordinates and
119             returned, e.g. returns (2, 3) for "0203".
120              
121             =cut
122              
123              
124             sub xy {
125 3461     3461 1 5724 my $self = shift;
126 3461         5877 my $coordinates = shift;
127 3461         11267 return (substr($coordinates, 0, 2), substr($coordinates, 2));
128             }
129              
130             =head2 legal($x, $y) or $legal($coordinates)
131              
132             say "legal" if $map->legal(10,10);
133              
134             Turn $coordinates into ($x, $y), assuming each to be two digits, i.e. "0203"
135             turns into (2, 3).
136              
137             Return ($x, $y) if the coordinates are legal, i.e. on the map.
138              
139             =cut
140              
141             sub legal {
142 1775     1775 1 4110 my $self = shift;
143 1775         4667 my ($x, $y) = @_;
144 1775 50       6668 ($x, $y) = $self->xy($x) if not defined $y;
145 1775 100 100     6414 return @_ if $x > 0 and $x <= $self->width and $y > 0 and $y <= $self->height;
      100        
      100        
146             }
147              
148             =head2 neighbors($hex)
149              
150             say join(" ", $map->neighbors("0203"));
151             # 0202 0303 0304 0204 0104 0103 0102
152              
153             Returns the list of legal neighbours, one step away. This could be just two
154             neighbours (e.g. around 0101).
155              
156             C<$hex> is an array reference of coordinates or a string that can be turned into
157             one using the C method.
158              
159             =cut
160              
161             sub all_neighbors {
162 227     227 0 2081 my $self = shift;
163 227         390 my $hex = shift;
164 227         590 return grep { $self->legal($_) } map { coord($self->neighbor($hex, $_)) } $self->neighbors;
  1362         14579  
  1362         3317  
165             }
166              
167             =head2 generate_plains
168              
169             All hexes are plains.
170              
171             =cut
172              
173             sub generate_plains {
174 1     1 1 3 my $self = shift;
175 1         8 for my $x (1 .. $self->width) {
176 10         40 for my $y (1 .. $self->height) {
177 100         286 $self->world->{coord($x,$y)} = 'plain';
178             }
179             }
180             }
181              
182             =head2 generate_ocean
183              
184             1d6-2 edges of the map are ocean. Randomly determine which ones. Every hex on
185             these edges is ocean. Every hex bordering an ocean hex has a 50% chance to be
186             ocean. Every hex bordering one of these secondary ocean hexes has a 33% chance
187             to be ocean, unless it has already been rolled for.
188              
189             =cut
190              
191             sub generate_ocean {
192 1     1 1 3 my $self = shift;
193 1         7 my $edges = int(rand(6))-2;
194 1 50       7 return if $edges < 0;
195 0         0 my @edges = shuffle(qw(north east south west));
196 0         0 for my $edge (@edges[0..$edges]) {
197 0 0       0 if ($edge eq 'west') {
    0          
    0          
    0          
198 0         0 for my $y (1 .. $self->height) {
199 0         0 $self->world->{coord(1,$y)} = 'ocean';
200             }
201             } elsif ($edge eq 'east') {
202 0         0 for my $y (1 .. $self->height) {
203 0         0 $self->world->{coord($self->width,$y)} = 'ocean';
204             }
205             } elsif ($edge eq 'north') {
206 0         0 for my $x (1 .. $self->width) {
207 0         0 $self->world->{coord($x,1)} = 'ocean';
208             }
209             } elsif ($edge eq 'south') {
210 0         0 for my $x (1 .. $self->width) {
211 0         0 $self->world->{coord($x,$self->height)} = 'ocean';
212             }
213             }
214             }
215 0         0 my @secondary;
216 0         0 for my $hex (grep { $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  0         0  
  0         0  
217 0         0 for my $other ($self->all_neighbors($hex)) {
218 0 0 0     0 if ($self->world->{$other} ne 'ocean' and rand() < 1/3) {
219 0         0 push(@secondary, $other);
220 0         0 $self->world->{$other} = 'ocean';
221             }
222             }
223             }
224 0         0 my %seen;
225 0         0 for my $hex (@secondary) {
226 0         0 for my $other ($self->all_neighbors($hex)) {
227 0 0       0 next if $seen{$other};
228 0         0 $seen{$other} = 1;
229 0 0 0     0 if ($self->world->{$other} ne 'ocean' and rand() < 0.5) {
230 0         0 $self->world->{$other} = 'ocean';
231             }
232             }
233             }
234 0         0 for my $hex (grep { $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  0         0  
  0         0  
235 0 0   0   0 if (any { $self->world->{$_} ne 'ocean' and $self->world->{$_} ne 'water' } $self->all_neighbors($hex)) {
  0 0       0  
236 0         0 $self->world->{$hex} = 'water';
237             }
238             }
239             }
240              
241             =head2 generate_mountains
242              
243             Place 1d6 mountain hexes. Roll two d10s for each to determine its coordinates.
244             If you end up in an ocean hex or a previous mountain hex, roll again. Every
245             plains hex adjacent to a mountain hex has a 4 in 6 chance to be mountains as
246             well; otherwise, it is hills. Repeat, but now with a 2 in 6 chance. Every plains
247             hex adjacent to a hill hex has a 3 in 6 chance to be hills.
248              
249             =cut
250              
251             sub generate_mountains {
252 1     1 1 3 my $self = shift;
253 1         4 my $m = int(rand(6))+1;
254 1         4 my $n = 0;
255 1         2 my @mountains;
256 1         5 while ($n < $m) {
257 2         6 my $x = int(rand($self->width))+1;
258 2         15 my $y = int(rand($self->height))+1;
259 2         13 my $coord = coord($x, $y);
260 2 50       7 if ($self->world->{$coord} eq 'plain') {
261 2         14 push(@mountains, $coord);
262 2         7 $self->world->{$coord} = 'mountain';
263 2         13 $n++;
264             }
265             }
266 1         3 for my $chance (2/3, 1/3, 0) {
267 3         31 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  300         1571  
  3         131  
268 33         268 for my $other ($self->all_neighbors($hex)) {
269 198 100       1642 if ($self->world->{$other} eq 'plain') {
270 48 100 100     397 if ($chance and rand() < $chance) {
271 18         37 $self->world->{$other} = 'mountain';
272             } else {
273 30         64 $self->world->{$other} = 'hills';
274             }
275             }
276             }
277             }
278             }
279 1         18 for my $hex (grep { $self->world->{$_} eq 'hills' } sort keys %{$self->world}) {
  100         569  
  1         4  
280 30         223 for my $other ($self->all_neighbors($hex)) {
281 166 100       1276 if ($self->world->{$other} eq 'plain') {
282 21         120 $self->world->{$other} = 'hills';
283             }
284             }
285             }
286             }
287              
288             =head2 rivers
289              
290             The original instructions are: "Roll 1d6 to determine how many major rivers
291             there are: 1 none, 2-4 one, 5 two, 6 two rivers joining into one. Each river has
292             a 3 in 6 chance to be flowing out of a mountain or hill hex; otherwise, it
293             enters from the edge of the map (if there is a land edge). If there is an ocean
294             on the map, the rivers will flow into it."
295              
296             Instead of doing that, let's try this: "A river starts in ever mountain and
297             every hill, flowing downwards if possible: from mountains to hills, from hills
298             to plains and from plains into the ocean or off the map. Pick the lowest lying
299             neighbour. We can mark these as canyons, later. When a river meets another
300             river, then merge them (same tail) or subsume them (if meerging with the
301             beginning of an existing river)."
302              
303             =cut
304              
305             sub generate_rivers {
306 1     1 0 3 my $self = shift;
307 1         4 my %seen;
308 1         5 local $" = "-";
309 1         3 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  100         546  
  1         5  
310 20 100       68 next if $seen{$hex};
311 19         45 my $river = [$hex];
312 19         60 $seen{$hex} = $river;
313 19         33 push(@{$self->rivers}, $river);
  19         55  
314 19         149 $self->wet->{$hex} = 1;
315 19         140 $log->debug("River starting at $hex");
316 19         150 while(1) {
317 54         306 my @neighbours = map { coord($self->neighbor($hex, $_)) } shuffle $self->neighbors;
  324         775  
318 54 100   285   437 my $end = first { not $self->legal($_) or $self->world->{$_} eq 'water' } @neighbours;
  285         4421  
319 54 100       1107 if ($end) {
320 11         61 $log->debug(" ends at $end");
321 11         140 push(@$river, $end);
322 11         61 last;
323             }
324             # $log->debug(" neighbours: " . join(", ", map { "$_: " . $self->world->{$_} } @neighbours));
325 43         183 @neighbours = sort { $self->altitude->{$self->world->{$a}} <=> $self->altitude->{$self->world->{$b}} } @neighbours;
  429         6279  
326 43         604 my $next = shift(@neighbours);
327 43 100       135 if ($seen{$next}) {
328 10         20 my @other = @{$seen{$next}};
  10         46  
329 10         100 $log->debug(" found river at $next: @other");
330             # avoid loops
331 10         141 while ($other[0] eq $river->[0]) {
332 2         6 $next = shift(@neighbours);
333 2 50       9 if ($seen{$next}) {
334 0         0 @other = @{$seen{$next}};
  0         0  
335 0         0 $log->debug(" nope, try again at $next: @other");
336             # check again
337             } else {
338 2         6 @other = ();
339 2         10 $log->debug(" nope, try again at $next (no river)");
340 2         17 last;
341             }
342             }
343 10 100       29 if (@other > 0) {
344 8 50       27 if ($other[0] eq $next) {
345 0         0 $log->debug(" flows into @other");
346             # append the other river hexes to this river and remove the other river from the list
347 0         0 push(@$river, @other);
348 0         0 $self->rivers([grep { $_->[0] ne $next } @{$self->rivers}]);
  0         0  
  0         0  
349             } else {
350 8         43 $log->debug(" merges into @other");
351             # copy the downstream hexes of the other river
352 8   66     119 shift(@other) while $other[0] and $other[0] ne $next;
353 8         41 push(@$river, @other);
354             }
355 8         41 last;
356             }
357 2 50       10 if (not $next) {
358             # with no other neighbour found, the river goes underground!?
359 0         0 $log->debug(" disappears");
360 0         0 last;
361             }
362             # if the neighbour is not a a river and exists, fall through
363             }
364 35         69 $hex = $next;
365 35         226 $log->debug(" flows to $hex");
366 35         519 push(@$river, $hex);
367 35         126 $seen{$hex} = $river;
368 35         107 $self->wet->{$hex} = 1;
369             }
370             }
371             }
372              
373             =head2 generate_canyons
374              
375             Check all the rivers: if it flows "uphill", add a canyon
376              
377             =cut
378              
379             sub generate_canyons {
380 1     1 1 3 my $self = shift;
381 1         3 local $" = "-";
382 1         2 my %seen;
383 1         2 my $canyon = [];
384 1         5 for my $river (@{$self->rivers}) {
  1         5  
385 19 50       54 next unless @$river > 2;
386 19         38 my $last = $river->[0];
387 19         45 my $current_altitude = $self->altitude->{$self->world->{$last}};
388 19         292 $log->debug("Looking at @$river ($current_altitude)");
389 19         179 for my $hex (@$river) {
390 73 100       197 if ($seen{$hex}) {
391 9 50       26 if (@$canyon == 0) {
    0          
    0          
392 9         25 last;
393             } elsif ($seen{$hex} == 1) {
394 0         0 push(@$canyon, $hex);
395 0         0 push(@{$self->canyons}, $canyon);
  0         0  
396 0         0 $canyon = [];
397 0         0 $log->debug(" ending cayon at known $hex");
398 0         0 $current_altitude = $self->altitude->{$self->world->{$hex}};
399 0         0 next;
400             } elsif ($seen{$hex} > 1) {
401 0         0 push(@{$self->canyons}, $canyon);
  0         0  
402 0         0 $canyon = [];
403 0         0 $log->debug(" merging cayon at $hex");
404             # FIXME
405 0         0 last;
406             }
407             }
408 64         200 $seen{$hex}++;
409 64 50 66     143 if ($self->legal($hex) and $self->altitude->{$self->world->{$hex}} > $current_altitude) {
    50          
    100          
410 0 0       0 if (@$canyon > 0) {
411 0         0 push(@$canyon, $hex);
412 0         0 $log->debug(" extending cayon to $hex");
413             } else {
414 0         0 $canyon = [$last, $hex];
415 0         0 $log->debug("Starting cayon @$canyon");
416             }
417 0         0 $seen{$hex}++; # more than 1 means this is inside a canyon
418             } elsif (@$canyon > 0) {
419 0         0 push(@$canyon, $hex);
420 0         0 push(@{$self->canyons}, $canyon);
  0         0  
421 0         0 $canyon = [];
422 0         0 $log->debug(" ending cayon at $hex");
423 0         0 $current_altitude = $self->altitude->{$self->world->{$hex}};
424             } elsif ($self->legal($hex)) {
425 54         732 $current_altitude = $self->altitude->{$self->world->{$hex}};
426             }
427 64         622 $last = $hex;
428             }
429             }
430             }
431              
432             =head2 generate_dry
433              
434             The wind blows from west or east. Follow the wind in straight horizontal lines.
435             Once the line hits a mountain, all the following hexes are dry hills or dry
436             plains except if it has a river.
437              
438             =cut
439              
440             sub generate_dry {
441 1     1 1 4 my $self = shift;
442 1 50       7 my $dir = rand() < 0.5 ? -1 : 1;
443 1 50       9 my $start = $dir == 1 ? 1 : $self->width;
444 1 50       6 my $end = $dir == 1 ? $self->width : 1;
445 1         8 for my $y (1 .. $self->height) {
446 10         72 my $dry = 0;
447 10 50       31 for (my $x = $start; $dir == 1 ? $x <= $end : $x >= $end; $x += $dir) {
448 100         853 my $hex = coord($x, $y);
449 100 100 100     358 if (not $dry and $self->world->{$hex} eq 'mountain') {
    100          
450 7 50       69 $log->debug("Going " . ($dir == 1 ? 'east' : 'west') . " from $hex is dry");
451 7         68 $dry = $x;
452             } elsif ($dry) {
453 29         65 my @hexes = ($hex);
454             # $dry contains the $x of the mountain. If $x something like 0306, we
455             # want to check 0405 (-1!) and 0406; if $x is something like 0607, we
456             # want to check 0707 and 0708 (+1). That is to say, it depends on
457             # whether the initial $x is even or odd. Also, it's always two hexes to
458             # check if the difference between the two $x coordinates is odd.
459 29 100       101 push(@hexes, coord($x, $y + ($dry % 2 ? -1 : +1))) if abs($x - $dry) % 2;
    100          
460 29         74 for my $hex2 (@hexes) {
461 45 100       169 next if $self->wet->{$hex2};
462 10         72 $log->debug(" $hex2 is dry");
463 10         86 $self->dry->{$hex2} = 1;
464             }
465             }
466             }
467             }
468             }
469              
470             =head2 generate_forest
471              
472             Every hex with a river has a 50% chance to be forested. Every hills or plains
473             hex without a river that isn’t dry or next to a dry hex has a 1 in 6 chance to
474             be forested; 2 in 6 if it is next to a forested river hex.
475              
476             =cut
477              
478             sub generate_forest {
479 1     1 1 3 my $self = shift;
480 1 50       3 my @land_hexes = grep { $self->world->{$_} ne 'water' and $self->world->{$_} ne 'ocean' } sort keys %{$self->world};
  100         1013  
  1         7  
481 1         45 my %forest_hexes;
482 1         5 for my $hex (@land_hexes) {
483 100 100 100     1067 if ($self->wet->{$hex} and rand() < 0.5
      100        
      100        
      100        
484             or not $self->dry->{$hex}
485 312     312   2229 and not any { $self->dry->{$_} } $self->all_neighbors($hex)
486             and rand() < 1/6) {
487 37 100       363 if ($self->world->{$hex} eq 'plain' ) {
    100          
488 8         54 $self->world->{$hex} = 'forest';
489 8         68 $forest_hexes{$hex} = 1;
490             } elsif ($self->world->{$hex} eq 'hills' ) {
491 17         178 $self->world->{$hex} = 'forest-hill';
492 17         138 $forest_hexes{$hex} = 1;
493             }
494             }
495             }
496             # since this pass relies on neighbours being forested
497 1         17 for my $hex (@land_hexes) {
498 100 100 100     623 if (not $self->dry->{$hex}
      100        
499 271     271   2425 and any { $forest_hexes{$_} } $self->all_neighbors($hex)
500             and rand() < 2/6) {
501 27 100       88 if ($self->world->{$hex} eq 'plain' ) {
    100          
502 4         31 $self->world->{$hex} = 'forest';
503             } elsif ($self->world->{$hex} eq 'hills' ) {
504 9         105 $self->world->{$hex} = 'forest-hill';
505             }
506             }
507             }
508             }
509              
510             =head2 generate_swamp
511              
512             A 1 in 6 chance on every plain river hex that isn't next to a dry hex.
513              
514             =cut
515              
516             sub generate_swamp {
517 1     1 1 3 my $self = shift;
518 1 100       2 for my $hex (grep { $self->world->{$_} eq 'plain' and $self->wet->{$_} } sort keys %{$self->world}) {
  100         673  
  1         6  
519 2 50   10   24 next if any { $self->dry->{$_} } $self->all_neighbors($hex);
  10         62  
520 2 50       26 if (rand() < 1/6) {
521 0         0 $self->world->{$hex} = 'swamp';
522             }
523             }
524             }
525              
526             =head2 generate_islands
527              
528             Every ocean hex has a 1 in 6 chance of having an island.
529              
530             =cut
531              
532             sub generate_islands {
533 1     1 1 4 my $self = shift;
534 1 50       3 for my $hex (grep { $self->world->{$_} eq 'water' or $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  100         985  
  1         6  
535 0 0       0 if (rand() < 1/6) {
536 0         0 $self->world->{$hex} .= " island";
537             }
538             }
539             }
540              
541             =head2 string
542              
543             Create the string output.
544              
545             =cut
546              
547             sub string {
548 1     1 1 5 my $self = shift;
549 100         597 return join("\n", map { $_ . " " . $self->world->{$_} } sort keys %{$self->world}) . "\n"
  1         4  
550 19         100 . join("\n", map { join("-", @$_) . " river" } @{$self->rivers}) . "\n"
  1         34  
551 1         3 . join("\n", map { join("-", @$_) . " canyon" } @{$self->canyons}) . "\n";
  0         0  
  1         5  
552             }
553              
554             =head2 generate_map
555              
556             Start with a 10 by 10 hexmap.
557              
558             =cut
559              
560             sub generate_map {
561 1     1 1 3 my $self = shift;
562 1         7 $self->generate_plains();
563 1         6 $self->generate_ocean();
564 1         5 $self->generate_mountains();
565 1         23 $self->generate_rivers();
566 1         18 $self->generate_canyons();
567 1         6 $self->generate_dry();
568 1         15 $self->generate_forest();
569 1         8 $self->generate_swamp();
570 1         13 $self->generate_islands();
571 1         24 return $self->string() . "\n"
572             . "include bright.txt\n";
573             }
574              
575             =head1 SEE ALSO
576              
577             Andreas Folkesten described this algorithm in the following blog post:
578             L.
579              
580             The map itself uses the I icons by Alex Schroeder. These are
581             dedicated to the public domain. See
582             L.
583              
584             =cut
585              
586             1;