File Coverage

blib/lib/Game/TextMapper/Smale.pm
Criterion Covered Total %
statement 126 155 81.2
branch 35 56 62.5
condition 38 66 57.5
subroutine 15 16 93.7
pod 1 12 8.3
total 215 305 70.4


line stmt bran cond sub pod time code
1             # Copyright (C) 2009-2021 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::Smale - generate fantasy wilderness maps
21              
22             =head1 SYNOPSIS
23              
24             my $text = Game::TextMapper::Smale->new
25             ->generate_map($width, $height, $bw);
26              
27             =head1 DESCRIPTION
28              
29             This generates a wilderness map based on the algorithm by Erin D. Smale. See the
30             blog posts at L and
31             L for more
32             information.
33              
34             Generally speaking, the idea is that the algorithm picks a random terrain for a
35             hex in the middle of the map. Based on that, the surrounding hexes a bit further
36             away are picked, and finally the remaining hexes are picked. This is why the
37             maps vary so drastically in terrain distribution.
38              
39             =head1 METHODS
40              
41             Note that this module acts as a class with the C method, but none
42             of the other subroutines defined are actual methods. They don't take a C<$self>
43             argument.
44              
45             =cut
46              
47             package Game::TextMapper::Smale;
48 1     1   6 use Game::TextMapper::Log;
  1         2  
  1         26  
49 1     1   4 use Game::TextMapper::Point;
  1         2  
  1         5  
50 1     1   23 use Modern::Perl '2018';
  1         1  
  1         4  
51 1     1   123 use Mojo::Base -base;
  1         2  
  1         6  
52              
53             my $log = Game::TextMapper::Log->get;
54              
55             my %world = ();
56              
57             # ATLAS HEX PRIMARY TERRAIN TYPE
58             # Water Swamp Desert Plains Forest Hills Mountains
59             # Water P W W W W W -
60             # Swamp W P - W W - -
61             # Desert W - P W - W W
62             # Plains S [1] S T P [4] S T -
63             # Forest T [2] T - S P [5] W [8] T [11]
64             # Hills W - S [3] T T [6] P [9] S
65             # Mountns - - W - W [7] S [10] P [12]
66             #
67             # 1. Treat as coastal (beach or scrub) if adjacent to water
68             # 2. 66% light forest
69             # 3. 33% rocky desert or high sand dunes
70             # 4. Treat as farmland in settled hexes
71             # 5. 33% heavy forest
72             # 6. 66% forested hills
73             # 7. 66% forested mountains
74             # 8. 33% forested hills
75             # 9. 20% canyon or fissure (not implemented)
76             # 10. 40% chance of a pass (not implemented)
77             # 11. 33% forested mountains
78             # 12. 20% chance of a dominating peak; 10% chance of a mountain pass (not
79             # implemented); 5% volcano (not implemented)
80             #
81             # Notes
82             # water: water
83             # sand: sand or dust
84             # swamp: dark-grey swamp (near trees) or dark-grey marshes (no trees)
85             # plains: light-green grass, bush or bushes near water or forest
86             # forest: green trees (light), green forest, dark-green forest (heavy);
87             # use firs and fir-forest near hills or mountains
88             # hill: light-grey hill, dust hill if sand dunes
89             # mountain: grey mountain, grey mountains (peak)
90              
91             # later, grass land near a settlement might get the colors soil or dark-soil!
92              
93             my %primary = ("water" => ["water"],
94             "swamp" => ["dark-grey swamp"],
95             "desert" => ["dust desert"],
96             "plains" => ["light-green grass"],
97             "forest" => ["green forest",
98             "green forest",
99             "dark-green fir-forest"],
100             "hill" => ["light-grey hill"],
101             "mountain" => ["grey mountain",
102             "grey mountain",
103             "grey mountain",
104             "grey mountain",
105             "grey mountains"]);
106              
107             my %secondary = ("water" => ["light-green grass",
108             "light-green bush",
109             "light-green bushes"],
110             "swamp" => ["light-green grass"],
111             "desert" => ["light-grey hill",
112             "light-grey hill",
113             "dust hill"],
114             "plains" => ["green forest"],
115             "forest" => ["light-green grass",
116             "light-green bush"],
117             "hill" => ["grey mountain"],
118             "mountain" => ["light-grey hill"]);
119              
120             my %tertiary = ("water" => ["green forest",
121             "green trees",
122             "green trees"],
123             "swamp" => ["green forest"],
124             "desert" => ["light-green grass"],
125             "plains" => ["light-grey hill"],
126             "forest" => ["light-grey forest-hill",
127             "light-grey forest-hill",
128             "light-grey hill"],
129             "hill" => ["light-green grass"],
130             "mountain" => ["green fir-forest",
131             "green forest",
132             "green forest-mountains"]);
133              
134             my %wildcard = ("water" => ["dark-grey swamp",
135             "dark-grey marsh",
136             "sand desert",
137             "dust desert",
138             "light-grey hill",
139             "light-grey forest-hill"],
140             "swamp" => ["water"],
141             "desert" => ["water",
142             "grey mountain"],
143             "plains" => ["water",
144             "dark-grey swamp",
145             "dust desert"],
146             "forest" => ["water",
147             "water",
148             "water",
149             "dark-grey swamp",
150             "dark-grey swamp",
151             "dark-grey marsh",
152             "grey mountain",
153             "grey forest-mountain",
154             "grey forest-mountains"],
155             "hill" => ["water",
156             "water",
157             "water",
158             "sand desert",
159             "sand desert",
160             "dust desert",
161             "green forest",
162             "green forest",
163             "green forest-hill"],
164             "mountain" => ["sand desert",
165             "dust desert"]);
166              
167              
168             my %reverse_lookup = (
169             # primary
170             "water" => "water",
171             "dark-grey swamp" => "swamp",
172             "dust desert" => "desert",
173             "light-green grass" => "plains",
174             "green forest" => "forest",
175             "dark-green fir-forest" => "forest",
176             "light-grey hill" => "hill",
177             "grey mountain" => "mountain",
178             "grey mountains" => "mountain",
179             # secondary
180             "light-green bush" => "plains",
181             "light-green bushes" => "plains",
182             "dust hill" => "hill",
183             # tertiary
184             "green trees" => "forest",
185             "light-grey forest-hill" => "hill",
186             "green fir-forest" => "forest",
187             "green forest-mountains" => "forest",
188             # wildcard
189             "dark-grey marsh" => "swamp",
190             "sand desert" => "desert",
191             "grey forest-mountain" => "mountain",
192             "grey forest-mountains" => "mountain",
193             "green forest-hill" => "forest",
194             # code
195             "light-soil fields" => "plains",
196             "soil fields" => "plains",
197             );
198              
199             my %encounters = ("settlement" => ["thorp", "thorp", "thorp", "thorp",
200             "village",
201             "town", "town",
202             "large-town",
203             "city"],
204             "fortress" => ["keep", "tower", "castle"],
205             "religious" => ["shrine", "law", "chaos"],
206             "ruin" => [],
207             "monster" => [],
208             "natural" => []);
209              
210             my @needs_fields;
211              
212             sub one {
213 935     935 0 1471 my @arr = @_;
214 935 100 66     2077 @arr = @{$arr[0]} if @arr == 1 and ref $arr[0] eq 'ARRAY';
  412         716  
215 935         2021 return $arr[int(rand(scalar @arr))];
216             }
217              
218             sub member {
219 0     0 0 0 my $element = shift;
220 0         0 foreach (@_) {
221 0 0       0 return 1 if $element eq $_;
222             }
223             }
224              
225             sub place_major {
226 4     4 0 8 my ($x, $y, $encounter) = @_;
227 4         18 my $thing = one(@{$encounters{$encounter}});
  4         8  
228 4 100       9 return unless $thing;
229 1         7 $log->debug("placing $thing ($encounter) at ($x,$y)");
230 1         10 my $hex = one(full_hexes($x, $y));
231 1         4 $x += $hex->[0];
232 1         2 $y += $hex->[1];
233 1         4 my $coordinates = Game::TextMapper::Point::coord($x, $y);
234 1         3 my $primary = $reverse_lookup{$world{$coordinates}};
235 1         4 my ($color, $terrain) = split(' ', $world{$coordinates}, 2);
236 1 50       3 if ($encounter eq 'settlement') {
237 0 0       0 if ($primary eq 'plains') {
238 0         0 $color = one('light-soil', 'soil');
239 0         0 $log->debug(" " . $world{$coordinates} . " is $primary and was changed to $color");
240             }
241 0 0 0     0 if ($primary ne 'plains' or member($thing, 'large-town', 'city')) {
242 0         0 push(@needs_fields, [$x, $y]);
243             }
244             }
245             # ignore $terrain for the moment and replace it with $thing
246 1         5 $world{$coordinates} = "$color $thing";
247             }
248              
249             sub populate_region {
250 15     15 0 23 my ($hex, $primary) = @_;
251 15         21 my $random = rand 100;
252 15 100 33     189 if ($primary eq 'water' and $random < 10
      33        
      33        
      33        
      33        
      33        
      33        
      100        
      66        
      100        
      100        
      66        
      100        
253             or $primary eq 'swamp' and $random < 20
254             or $primary eq 'sand' and $random < 20
255             or $primary eq 'grass' and $random < 60
256             or $primary eq 'forest' and $random < 40
257             or $primary eq 'hill' and $random < 40
258             or $primary eq 'mountain' and $random < 20) {
259 4         15 place_major($hex->[0], $hex->[1], one(keys %encounters));
260             }
261             }
262              
263             # Brute forcing by picking random sub hexes until we found an
264             # unassigned one.
265              
266             sub pick_unassigned {
267 225     225 0 434 my ($x, $y, @region) = @_;
268 225         333 my $hex = one(@region);
269 225         507 my $coordinates = Game::TextMapper::Point::coord($x + $hex->[0], $y + $hex->[1]);
270 225         499 while ($world{$coordinates}) {
271 288         466 $hex = one(@region);
272 288         626 $coordinates = Game::TextMapper::Point::coord($x + $hex->[0], $y + $hex->[1]);
273             }
274 225         460 return $coordinates;
275             }
276              
277             sub pick_remaining {
278 30     30 0 56 my ($x, $y, @region) = @_;
279 30         43 my @coordinates = ();
280 30         44 for my $hex (@region) {
281 465         845 my $coordinates = Game::TextMapper::Point::coord($x + $hex->[0], $y + $hex->[1]);
282 465 100       1063 push(@coordinates, $coordinates) unless $world{$coordinates};
283             }
284 30         104 return @coordinates;
285             }
286              
287             # Precomputed for speed
288              
289             sub full_hexes {
290 16     16 0 24 my ($x, $y) = @_;
291 16 100       32 if ($x % 2) {
292 9         61 return ([0, -2],
293             [-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1],
294             [-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0],
295             [-2, 1], [-1, 1], [0, 1], [1, 1], [2, 1],
296             [-1, 2], [0, 2], [1, 2]);
297             } else {
298 7         53 return ([-1, -2], [0, -2], [1, -2],
299             [-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1],
300             [-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0],
301             [-2, 1], [-1, 1], [0, 1], [1, 1], [2, 1],
302             [0, 2]);
303             }
304             }
305              
306             sub half_hexes {
307 15     15 0 23 my ($x, $y) = @_;
308 15 100       29 if ($x % 2) {
309 9         51 return ([-2, -2], [-1, -2], [1, -2], [2, -2],
310             [-3, 0], [3, 0],
311             [-3, 1], [3, 1],
312             [-2, 2], [2, 2],
313             [-1, 3], [1, 3]);
314             } else {
315 6         30 return ([-1, -3], [1, -3],
316             [-2, -2], [2, -2],
317             [-3, -1], [3, -1],
318             [-3, 0], [3, 0],
319             [-2, 2], [-1, 2], [1, 2], [2, 2]);
320             }
321             }
322              
323             sub generate_region {
324 15     15 0 28 my ($x, $y, $primary) = @_;
325 15         34 $world{Game::TextMapper::Point::coord($x, $y)} = one($primary{$primary});
326              
327 15         35 my @region = full_hexes($x, $y);
328 15         19 my $terrain;
329              
330 15         29 for (1..9) {
331 135         242 my $coordinates = pick_unassigned($x, $y, @region);
332 135         286 $terrain = one($primary{$primary});
333 135         380 $log->debug(" primary $coordinates => $terrain");
334 135         893 $world{$coordinates} = $terrain;
335             }
336              
337 15         26 for (1..6) {
338 90         155 my $coordinates = pick_unassigned($x, $y, @region);
339 90         142 $terrain = one($secondary{$primary});
340 90         302 $log->debug(" secondary $coordinates => $terrain");
341 90         647 $world{$coordinates} = $terrain;
342             }
343              
344 15         33 for my $coordinates (pick_remaining($x, $y, @region)) {
345 33 100       66 if (rand > 0.1) {
346 30         57 $terrain = one($tertiary{$primary});
347 30         93 $log->debug(" tertiary $coordinates => $terrain");
348             } else {
349 3         6 $terrain = one($wildcard{$primary});
350 3         8 $log->debug(" wildcard $coordinates => $terrain");
351             }
352 33         233 $world{$coordinates} = $terrain;
353             }
354              
355 15         36 for my $coordinates (pick_remaining($x, $y, half_hexes($x, $y))) {
356 124         172 my $random = rand 6;
357 124 100       209 if ($random < 3) {
    100          
358 57         81 $terrain = one($primary{$primary});
359 57         146 $log->debug(" halfhex primary $coordinates => $terrain");
360             } elsif ($random < 5) {
361 42         60 $terrain = one($secondary{$primary});
362 42         99 $log->debug(" halfhex secondary $coordinates => $terrain");
363             } else {
364 25         35 $terrain = one($tertiary{$primary});
365 25         59 $log->debug(" halfhex tertiary $coordinates => $terrain");
366             }
367 124         872 $world{$coordinates} = $terrain;
368             }
369             }
370              
371             sub seed_region {
372 1     1 0 3 my ($seeds, $terrain) = @_;
373 1         2 my $terrain_above;
374 1         2 for my $hex (@$seeds) {
375 15         56 $log->debug("seed_region (" . $hex->[0] . "," . $hex->[1] . ") with $terrain");
376 15         114 generate_region($hex->[0], $hex->[1], $terrain);
377 15         65 populate_region($hex, $terrain);
378 15         22 my $random = rand 12;
379             # pick next terrain based on the previous one (to the left); or the one
380             # above if in the first column
381 15         17 my $next;
382 15 100 100     32 $terrain = $terrain_above if $hex->[0] == 1 and $terrain_above;
383 15 100       32 if ($random < 6) {
    100          
    100          
384 5         11 $next = one($primary{$terrain});
385 5         14 $log->debug("picked primary $next");
386             } elsif ($random < 9) {
387 5         12 $next = one($secondary{$terrain});
388 5         16 $log->debug("picked secondary $next");
389             } elsif ($random < 11) {
390 2         4 $next = one($tertiary{$terrain});
391 2         8 $log->debug("picked tertiary $next");
392             } else {
393 3         6 $next = one($wildcard{$terrain});
394 3         10 $log->debug("picked wildcard $next");
395             }
396 15 100       85 $terrain_above = $terrain if $hex->[0] == 1;
397 15 50       42 die "Terrain lacks reverse_lookup: $next\n" unless $reverse_lookup{$next};
398 15         28 $terrain = $reverse_lookup{$next};
399             }
400             }
401              
402             sub agriculture {
403 1     1 0 3 for my $hex (@needs_fields) {
404 0         0 $log->debug("looking to plant fields near " . Game::TextMapper::Point::coord($hex->[0], $hex->[1]));
405 0         0 my $delta = [[[-1, 0], [ 0, -1], [+1, 0], [+1, +1], [ 0, +1], [-1, +1]], # x is even
406             [[-1, -1], [ 0, -1], [+1, -1], [+1, 0], [ 0, +1], [-1, 0]]]; # x is odd
407 0         0 my @plains;
408 0         0 for my $i (0 .. 5) {
409 0         0 my ($x, $y) = ($hex->[0] + $delta->[$hex->[0] % 2]->[$i]->[0],
410             $hex->[1] + $delta->[$hex->[0] % 2]->[$i]->[1]);
411 0         0 my $coordinates = Game::TextMapper::Point::coord($x, $y);
412 0 0       0 if ($world{$coordinates}) {
413 0         0 my ($color, $terrain) = split(' ', $world{$coordinates}, 2);
414 0         0 $log->debug(" $coordinates is " . $world{$coordinates} . " ie. " . $reverse_lookup{$world{$coordinates}});
415 0 0       0 if ($reverse_lookup{$world{$coordinates}} eq 'plains') {
416 0         0 $log->debug(" $coordinates is a candidate");
417 0         0 push(@plains, $coordinates);
418             }
419             }
420             }
421 0 0       0 next unless @plains;
422 0         0 my $target = one(@plains);
423 0         0 $world{$target} = one('light-soil fields', 'soil fields');
424 0         0 $log->debug(" $target planted with " . $world{$target});
425             }
426             }
427              
428             =head2 generate_map WIDTH, HEIGHT, BW
429              
430             WIDTH and HEIGHT default to 20×10.
431              
432             BW stands for "black & white", i.e. a true value skips background colours.
433              
434             =cut
435              
436             sub generate_map {
437 1     1 1 10 my ($self, $width, $height, $bw) = @_;
438 1 0 33     5 $width = 20 if not defined $width or $width < 1 or $width > 100;
      33        
439 1 0 33     18 $height = 10 if not defined $height or $height < 1 or $height > 100;
      33        
440              
441 1         1 my $seeds;
442 1         5 for (my $y = 1; $y < $height + 3; $y += 5) {
443 3         8 for (my $x = 1; $x < $width + 3; $x += 5) {
444             # [1,1] [6,3], [11,1], [16,3]
445 15         21 my $y0 = $y + int(($x % 10) / 3);
446 15         32 push(@$seeds, [$x, $y0]);
447             }
448             }
449              
450 1         3 %world = (); # reinitialize!
451              
452 1         5 my @seed_terrain = keys %primary;
453 1         5 seed_region($seeds, one(@seed_terrain));
454 1         4 agriculture();
455              
456             # delete extra hexes we generated to fill the gaps
457 1         70 for my $coordinates (keys %world) {
458 397         789 $coordinates =~ /(-?\d\d)(-?\d\d)/;
459 397 100 100     1070 delete $world{$coordinates} if $1 < 1 or $2 < 1;
460 397 100 100     1086 delete $world{$coordinates} if $1 > $width or $2 > $height;
461             }
462 1 50       25 if ($bw) {
463 0         0 for my $coordinates (keys %world) {
464 0         0 my ($color, $rest) = split(' ', $world{$coordinates}, 2);
465 0 0       0 if ($rest) {
466 0         0 $world{$coordinates} = $rest;
467             } else {
468 0         0 delete $world{$coordinates};
469             }
470             }
471             }
472              
473 1         55 return join("\n", map { $_ . " " . $world{$_} } sort keys %world) . "\n"
  200         393  
474             . "include gnomeyland.txt\n";
475             }
476              
477             =head1 SEE ALSO
478              
479             Erin D. Smale described this algorithm in two famous blog posts:
480             L and
481             L.
482              
483             The map itself uses the I icons by Gregory B. MacKenzie. These are
484             licensed under the Creative Commons Attribution-ShareAlike 3.0 Unported License.
485             To view a copy of this license, visit
486             L.
487              
488             =cut
489              
490             1;