File Coverage

blib/lib/Game/TextMapper/Folkesten.pm
Criterion Covered Total %
statement 204 269 75.8
branch 70 114 61.4
condition 37 45 82.2
subroutine 25 27 92.5
pod 15 17 88.2
total 351 472 74.3


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   63 use Game::TextMapper::Log;
  11         27  
  11         396  
42 11     11   70 use Game::TextMapper::Point;
  11         19  
  11         78  
43 11     11   301 use Modern::Perl '2018';
  11         20  
  11         59  
44 11     11   3096 use Mojo::Base -base;
  11         19  
  11         75  
45 11     11   3855 use List::Util qw(shuffle any first);
  11         18  
  11         62788  
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 282     282 1 550 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 1692     1692 1 1950 my $self = shift;
103             # $hex is [x,y] or "0x0y" and $i is a number 0 .. 5
104 1692         2213 my ($hex, $i) = @_;
105 1692 50       2400 die join(":", caller) . ": undefined direction for $hex\n" unless defined $i;
106 1692 50       2726 $hex = [$self->xy($hex)] unless ref $hex;
107 1692         5716 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 1692         6887 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 3454     3454 1 4062 my $self = shift;
126 3454         4120 my $coordinates = shift;
127 3454         6882 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 1762     1762 1 3019 my $self = shift;
143 1762         2340 my ($x, $y) = @_;
144 1762 50       3084 ($x, $y) = $self->xy($x) if not defined $y;
145 1762 100 100     3802 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 239     239 0 1300 my $self = shift;
163 239         297 my $hex = shift;
164 239         423 return grep { $self->legal($_) } map { coord($self->neighbor($hex, $_)) } $self->neighbors;
  1434         8941  
  1434         2216  
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         38 for my $y (1 .. $self->height) {
177 100         222 $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 2 my $self = shift;
193 1         5 my $edges = int(rand(6))-2;
194 1 50       5 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 2 my $self = shift;
253 1         2 my $m = int(rand(6))+1;
254 1         2 my $n = 0;
255 1         1 my @mountains;
256 1         3 while ($n < $m) {
257 5         7 my $x = int(rand($self->width))+1;
258 5         16 my $y = int(rand($self->height))+1;
259 5         16 my $coord = coord($x, $y);
260 5 50       9 if ($self->world->{$coord} eq 'plain') {
261 5         18 push(@mountains, $coord);
262 5         8 $self->world->{$coord} = 'mountain';
263 5         15 $n++;
264             }
265             }
266 1         2 for my $chance (2/3, 1/3, 0) {
267 3         18 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  300         889  
  3         78  
268 35         139 for my $other ($self->all_neighbors($hex)) {
269 194 100       749 if ($self->world->{$other} eq 'plain') {
270 30 100 100     118 if ($chance and rand() < $chance) {
271 12         18 $self->world->{$other} = 'mountain';
272             } else {
273 18         32 $self->world->{$other} = 'hills';
274             }
275             }
276             }
277             }
278             }
279 1         10 for my $hex (grep { $self->world->{$_} eq 'hills' } sort keys %{$self->world}) {
  100         286  
  1         4  
280 18         68 for my $other ($self->all_neighbors($hex)) {
281 95 100       355 if ($self->world->{$other} eq 'plain') {
282 15         51 $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 1 my $self = shift;
307 1         3 my %seen;
308 1         3 local $" = "-";
309 1         3 for my $hex (grep { $self->world->{$_} eq 'mountain' } sort keys %{$self->world}) {
  100         293  
  1         4  
310 17 100       34 next if $seen{$hex};
311 16         21 my $river = [$hex];
312 16         27 $seen{$hex} = $river;
313 16         18 push(@{$self->rivers}, $river);
  16         30  
314 16         54 $self->wet->{$hex} = 1;
315 16         65 $log->debug("River starting at $hex");
316 16         65 while(1) {
317 43         139 my @neighbours = map { coord($self->neighbor($hex, $_)) } shuffle $self->neighbors;
  258         341  
318 43 100   234   161 my $end = first { not $self->legal($_) or $self->world->{$_} eq 'water' } @neighbours;
  234         1974  
319 43 100       443 if ($end) {
320 8         24 $log->debug(" ends at $end");
321 8         41 push(@$river, $end);
322 8         20 last;
323             }
324             # $log->debug(" neighbours: " . join(", ", map { "$_: " . $self->world->{$_} } @neighbours));
325 35         76 @neighbours = sort { $self->altitude->{$self->world->{$a}} <=> $self->altitude->{$self->world->{$b}} } @neighbours;
  352         2386  
326 35         313 my $next = shift(@neighbours);
327 35 100       157 if ($seen{$next}) {
328 9         11 my @other = @{$seen{$next}};
  9         29  
329 9         63 $log->debug(" found river at $next: @other");
330             # avoid loops
331 9         59 while ($other[0] eq $river->[0]) {
332 1         3 $next = shift(@neighbours);
333 1 50       10 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 1         4 @other = ();
339 1         7 $log->debug(" nope, try again at $next (no river)");
340 1         8 last;
341             }
342             }
343 9 100       17 if (@other > 0) {
344 8 100       14 if ($other[0] eq $next) {
345 2         8 $log->debug(" flows into @other");
346             # append the other river hexes to this river and remove the other river from the list
347 2         9 push(@$river, @other);
348 2         2 $self->rivers([grep { $_->[0] ne $next } @{$self->rivers}]);
  14         24  
  2         6  
349             } else {
350 6         21 $log->debug(" merges into @other");
351             # copy the downstream hexes of the other river
352 6   66     62 shift(@other) while $other[0] and $other[0] ne $next;
353 6         20 push(@$river, @other);
354             }
355 8         44 last;
356             }
357 1 50       30 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 27         39 $hex = $next;
365 27         84 $log->debug(" flows to $hex");
366 27         182 push(@$river, $hex);
367 27         48 $seen{$hex} = $river;
368 27         51 $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         4 my $canyon = [];
384 1         3 for my $river (@{$self->rivers}) {
  1         5  
385 14 100       37 next unless @$river > 2;
386 12         23 my $last = $river->[0];
387 12         28 my $current_altitude = $self->altitude->{$self->world->{$last}};
388 12         150 $log->debug("Looking at @$river ($current_altitude)");
389 12         99 for my $hex (@$river) {
390 53 100       128 if ($seen{$hex}) {
391 6 50       14 if (@$canyon == 0) {
    0          
    0          
392 6         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 47         116 $seen{$hex}++;
409 47 50 66     116 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 41         501 $current_altitude = $self->altitude->{$self->world->{$hex}};
426             }
427 47         357 $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       7 my $dir = rand() < 0.5 ? -1 : 1;
443 1 50       6 my $start = $dir == 1 ? 1 : $self->width;
444 1 50       12 my $end = $dir == 1 ? $self->width : 1;
445 1         6 for my $y (1 .. $self->height) {
446 10         69 my $dry = 0;
447 10 50       34 for (my $x = $start; $dir == 1 ? $x <= $end : $x >= $end; $x += $dir) {
448 100         661 my $hex = coord($x, $y);
449 100 100 100     301 if (not $dry and $self->world->{$hex} eq 'mountain') {
    100          
450 6 50       62 $log->debug("Going " . ($dir == 1 ? 'east' : 'west') . " from $hex is dry");
451 6         59 $dry = $x;
452             } elsif ($dry) {
453 16         33 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 16 100       56 push(@hexes, coord($x, $y + ($dry % 2 ? -1 : +1))) if abs($x - $dry) % 2;
    100          
460 16         34 for my $hex2 (@hexes) {
461 25 100       121 next if $self->wet->{$hex2};
462 3         21 $log->debug(" $hex2 is dry");
463 3         23 $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 4 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         1014  
  1         5  
481 1         28 my %forest_hexes;
482 1         14 for my $hex (@land_hexes) {
483 100 100 100     680 if ($self->wet->{$hex} and rand() < 0.5
      100        
      100        
      100        
484             or not $self->dry->{$hex}
485 424     424   1743 and not any { $self->dry->{$_} } $self->all_neighbors($hex)
486             and rand() < 1/6) {
487 29 100       170 if ($self->world->{$hex} eq 'plain' ) {
    100          
488 12         53 $self->world->{$hex} = 'forest';
489 12         70 $forest_hexes{$hex} = 1;
490             } elsif ($self->world->{$hex} eq 'hills' ) {
491 11         76 $self->world->{$hex} = 'forest-hill';
492 11         65 $forest_hexes{$hex} = 1;
493             }
494             }
495             }
496             # since this pass relies on neighbours being forested
497 1         9 for my $hex (@land_hexes) {
498 100 100 100     260 if (not $self->dry->{$hex}
      100        
499 305     305   1499 and any { $forest_hexes{$_} } $self->all_neighbors($hex)
500             and rand() < 2/6) {
501 15 100       31 if ($self->world->{$hex} eq 'plain' ) {
    100          
502 4         29 $self->world->{$hex} = 'forest';
503             } elsif ($self->world->{$hex} eq 'hills' ) {
504 5         33 $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         411  
  1         5  
519 6 50   32   34 next if any { $self->dry->{$_} } $self->all_neighbors($hex);
  32         190  
520 6 100       49 if (rand() < 1/6) {
521 1         5 $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 2 my $self = shift;
534 1 50       2 for my $hex (grep { $self->world->{$_} eq 'water' or $self->world->{$_} eq 'ocean' } sort keys %{$self->world}) {
  100         539  
  1         4  
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 3 my $self = shift;
549 100         323 return join("\n", map { $_ . " " . $self->world->{$_} } sort keys %{$self->world}) . "\n"
  1         2  
550 14         54 . join("\n", map { join("-", @$_) . " river" } @{$self->rivers}) . "\n"
  1         12  
551 1         2 . join("\n", map { join("-", @$_) . " canyon" } @{$self->canyons}) . "\n";
  0         0  
  1         4  
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 4 my $self = shift;
562 1         7 $self->generate_plains();
563 1         5 $self->generate_ocean();
564 1         5 $self->generate_mountains();
565 1         13 $self->generate_rivers();
566 1         17 $self->generate_canyons();
567 1         8 $self->generate_dry();
568 1         14 $self->generate_forest();
569 1         6 $self->generate_swamp();
570 1         10 $self->generate_islands();
571 1         13 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;