File Coverage

blib/lib/Game/TextMapper/Folkesten.pm
Criterion Covered Total %
statement 235 269 87.3
branch 88 114 77.1
condition 41 45 91.1
subroutine 26 27 96.3
pod 15 17 88.2
total 405 472 85.8


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   80 use Game::TextMapper::Log;
  11         26  
  11         436  
42 11     11   64 use Game::TextMapper::Point;
  11         23  
  11         91  
43 11     11   372 use Modern::Perl '2018';
  11         25  
  11         67  
44 11     11   3785 use Mojo::Base -base;
  11         40  
  11         79  
45 11     11   2194 use List::Util qw(shuffle any first);
  11         54  
  11         84632  
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 285     285 1 811 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 1710     1710 1 2498 my $self = shift;
103             # $hex is [x,y] or "0x0y" and $i is a number 0 .. 5
104 1710         3190 my ($hex, $i) = @_;
105 1710 50       3311 die join(":", caller) . ": undefined direction for $hex\n" unless defined $i;
106 1710 50       4388 $hex = [$self->xy($hex)] unless ref $hex;
107 1710         9129 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 1710         10917 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 4962 my $self = shift;
126 3461         5098 my $coordinates = shift;
127 3461         9602 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 1751     1751 1 3088 my $self = shift;
143 1751         2859 my ($x, $y) = @_;
144 1751 50       4206 ($x, $y) = $self->xy($x) if not defined $y;
145 1751 100 100     5597 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 236     236 0 1638 my $self = shift;
163 236         376 my $hex = shift;
164 236         603 return grep { $self->legal($_) } map { coord($self->neighbor($hex, $_)) } $self->neighbors;
  1416         13986  
  1416         3523  
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         7 for my $x (1 .. $self->width) {
176 10         40 for my $y (1 .. $self->height) {
177 100         280 $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       5 return if $edges < 0;
195 1         12 my @edges = shuffle(qw(north east south west));
196 1         7 for my $edge (@edges[0..$edges]) {
197 1 50       9 if ($edge eq 'west') {
    50          
    50          
    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 1         5 for my $x (1 .. $self->width) {
207 10         29 $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 1         4 my @secondary;
216 1         3 for my $hex (grep { $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  100         554  
  1         5  
217 10         90 for my $other ($self->all_neighbors($hex)) {
218 37 100 100     313 if ($self->world->{$other} ne 'ocean' and rand() < 1/3) {
219 4         52 push(@secondary, $other);
220 4         12 $self->world->{$other} = 'ocean';
221             }
222             }
223             }
224 1         15 my %seen;
225 1         4 for my $hex (@secondary) {
226 4         20 for my $other ($self->all_neighbors($hex)) {
227 24 100       208 next if $seen{$other};
228 20         47 $seen{$other} = 1;
229 20 100 100     47 if ($self->world->{$other} ne 'ocean' and rand() < 0.5) {
230 4         31 $self->world->{$other} = 'ocean';
231             }
232             }
233             }
234 1         11 for my $hex (grep { $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  100         510  
  1         5  
235 18 100   52   261 if (any { $self->world->{$_} ne 'ocean' and $self->world->{$_} ne 'water' } $self->all_neighbors($hex)) {
  52 100       516  
236 15         162 $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         6 my $m = int(rand(6))+1;
254 1         2 my $n = 0;
255 1         3 my @mountains;
256 1         5 while ($n < $m) {
257 7         24 my $x = int(rand($self->width))+1;
258 7         39 my $y = int(rand($self->height))+1;
259 7         34 my $coord = coord($x, $y);
260 7 100       17 if ($self->world->{$coord} eq 'plain') {
261 6         32 push(@mountains, $coord);
262 6         17 $self->world->{$coord} = 'mountain';
263 6         29 $n++;
264             }
265             }
266 1         5 for my $chance (2/3, 1/3, 0) {
267 3         30 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  300         1541  
  3         11  
268 50         392 for my $other ($self->all_neighbors($hex)) {
269 272 100       1813 if ($self->world->{$other} eq 'plain') {
270 39 100 100     276 if ($chance and rand() < $chance) {
271 18         72 $self->world->{$other} = 'mountain';
272             } else {
273 21         46 $self->world->{$other} = 'hills';
274             }
275             }
276             }
277             }
278             }
279 1         12 for my $hex (grep { $self->world->{$_} eq 'hills' } sort keys %{$self->world}) {
  100         282  
  1         5  
280 21         91 for my $other ($self->all_neighbors($hex)) {
281 117 100       533 if ($self->world->{$other} eq 'plain') {
282 12         40 $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         2 my %seen;
308 1         78 local $" = "-";
309 1         4 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  100         325  
  1         4  
310 24 100       44 next if $seen{$hex};
311 23         31 my $river = [$hex];
312 23         43 $seen{$hex} = $river;
313 23         22 push(@{$self->rivers}, $river);
  23         35  
314 23         73 $self->wet->{$hex} = 1;
315 23         93 $log->debug("River starting at $hex");
316 23         89 while(1) {
317 49         121 my @neighbours = map { coord($self->neighbor($hex, $_)) } shuffle $self->neighbors;
  294         382  
318 49 100   239   171 my $end = first { not $self->legal($_) or $self->world->{$_} eq 'water' } @neighbours;
  239         1747  
319 49 100       475 if ($end) {
320 15         49 $log->debug(" ends at $end");
321 15         85 push(@$river, $end);
322 15         35 last;
323             }
324             # $log->debug(" neighbours: " . join(", ", map { "$_: " . $self->world->{$_} } @neighbours));
325 34         64 @neighbours = sort { $self->altitude->{$self->world->{$a}} <=> $self->altitude->{$self->world->{$b}} } @neighbours;
  336         1977  
326 34         230 my $next = shift(@neighbours);
327 34 100       55 if ($seen{$next}) {
328 10         10 my @other = @{$seen{$next}};
  10         27  
329 10         39 $log->debug(" found river at $next: @other");
330             # avoid loops
331 10         51 while ($other[0] eq $river->[0]) {
332 5         15 $next = shift(@neighbours);
333 5 100       9 if ($seen{$next}) {
334 3         6 @other = @{$seen{$next}};
  3         7  
335 3         7 $log->debug(" nope, try again at $next: @other");
336             # check again
337             } else {
338 2         4 @other = ();
339 2         4 $log->debug(" nope, try again at $next (no river)");
340 2         8 last;
341             }
342             }
343 10 100       17 if (@other > 0) {
344 8 100       16 if ($other[0] eq $next) {
345 1         5 $log->debug(" flows into @other");
346             # append the other river hexes to this river and remove the other river from the list
347 1         7 push(@$river, @other);
348 1         2 $self->rivers([grep { $_->[0] ne $next } @{$self->rivers}]);
  17         25  
  1         4  
349             } else {
350 7         20 $log->debug(" merges into @other");
351             # copy the downstream hexes of the other river
352 7   66     47 shift(@other) while $other[0] and $other[0] ne $next;
353 7         29 push(@$river, @other);
354             }
355 8         23 last;
356             }
357 2 50       4 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 26         30 $hex = $next;
365 26         61 $log->debug(" flows to $hex");
366 26         133 push(@$river, $hex);
367 26         42 $seen{$hex} = $river;
368 26         38 $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 1 my $self = shift;
381 1         3 local $" = "-";
382 1         2 my %seen;
383 1         2 my $canyon = [];
384 1         1 for my $river (@{$self->rivers}) {
  1         3  
385 22 100       79 next unless @$river > 2;
386 14         20 my $last = $river->[0];
387 14         25 my $current_altitude = $self->altitude->{$self->world->{$last}};
388 14         106 $log->debug("Looking at @$river ($current_altitude)");
389 14         74 for my $hex (@$river) {
390 55 100       93 if ($seen{$hex}) {
391 7 50       13 if (@$canyon == 0) {
    0          
    0          
392 7         13 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 48         74 $seen{$hex}++;
409 48 50 66     67 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 43         351 $current_altitude = $self->altitude->{$self->world->{$hex}};
426             }
427 48         255 $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 3 my $self = shift;
442 1 50       8 my $dir = rand() < 0.5 ? -1 : 1;
443 1 50       5 my $start = $dir == 1 ? 1 : $self->width;
444 1 50       6 my $end = $dir == 1 ? $self->width : 1;
445 1         7 for my $y (1 .. $self->height) {
446 10         65 my $dry = 0;
447 10 50       48 for (my $x = $start; $dir == 1 ? $x <= $end : $x >= $end; $x += $dir) {
448 100         773 my $hex = coord($x, $y);
449 100 100 100     307 if (not $dry and $self->world->{$hex} eq 'mountain') {
    100          
450 8 50       131 $log->debug("Going " . ($dir == 1 ? 'east' : 'west') . " from $hex is dry");
451 8         95 $dry = $x;
452             } elsif ($dry) {
453 23         53 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 23 100       81 push(@hexes, coord($x, $y + ($dry % 2 ? -1 : +1))) if abs($x - $dry) % 2;
    100          
460 23         51 for my $hex2 (@hexes) {
461 37 100       163 next if $self->wet->{$hex2};
462 1         13 $log->debug(" $hex2 is dry");
463 1         12 $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 100       3 my @land_hexes = grep { $self->world->{$_} ne 'water' and $self->world->{$_} ne 'ocean' } sort keys %{$self->world};
  100         928  
  1         4  
481 1         50 my %forest_hexes;
482 1         5 for my $hex (@land_hexes) {
483 82 100 100     906 if ($self->wet->{$hex} and rand() < 0.5
      66        
      100        
      100        
484             or not $self->dry->{$hex}
485 250     250   1712 and not any { $self->dry->{$_} } $self->all_neighbors($hex)
486             and rand() < 1/6) {
487 38 100       369 if ($self->world->{$hex} eq 'plain' ) {
    100          
488 6         40 $self->world->{$hex} = 'forest';
489 6         65 $forest_hexes{$hex} = 1;
490             } elsif ($self->world->{$hex} eq 'hills' ) {
491 14         154 $self->world->{$hex} = 'forest-hill';
492 14         106 $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 82 100 66     518 if (not $self->dry->{$hex}
      100        
499 263     263   2220 and any { $forest_hexes{$_} } $self->all_neighbors($hex)
500             and rand() < 2/6) {
501 25 100       86 if ($self->world->{$hex} eq 'plain' ) {
    100          
502 5         41 $self->world->{$hex} = 'forest';
503             } elsif ($self->world->{$hex} eq 'hills' ) {
504 4         47 $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 4 my $self = shift;
518 1 100       3 for my $hex (grep { $self->world->{$_} eq 'plain' and $self->wet->{$_} } sort keys %{$self->world}) {
  100         827  
  1         6  
519 1 50   4   15 next if any { $self->dry->{$_} } $self->all_neighbors($hex);
  4         20  
520 1 50       54 if (rand() < 1/6) {
521 1         7 $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 100       3 for my $hex (grep { $self->world->{$_} eq 'water' or $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  100         888  
  1         4  
535 18 100       79 if (rand() < 1/6) {
536 3         10 $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 4 my $self = shift;
549 100         572 return join("\n", map { $_ . " " . $self->world->{$_} } sort keys %{$self->world}) . "\n"
  1         4  
550 22         103 . join("\n", map { join("-", @$_) . " river" } @{$self->rivers}) . "\n"
  1         20  
551 1         3 . join("\n", map { join("-", @$_) . " canyon" } @{$self->canyons}) . "\n";
  0         0  
  1         7  
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 2 my $self = shift;
562 1         7 $self->generate_plains();
563 1         6 $self->generate_ocean();
564 1         26 $self->generate_mountains();
565 1         12 $self->generate_rivers();
566 1         9 $self->generate_canyons();
567 1         6 $self->generate_dry();
568 1         14 $self->generate_forest();
569 1         8 $self->generate_swamp();
570 1         22 $self->generate_islands();
571 1         14 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;