File Coverage

blib/lib/Game/TextMapper/Apocalypse.pm
Criterion Covered Total %
statement 130 131 99.2
branch 41 46 89.1
condition 5 6 83.3
subroutine 18 18 100.0
pod 1 11 9.0
total 195 212 91.9


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::Apocalypse - generate postapocalyptic landscape
21              
22             =head1 SYNOPSIS
23              
24             use Modern::Perl;
25             use Game::TextMapper::Apocalypse;
26             my $map = Game::TextMapper::Apocalypse->new->generate_map();
27             print $map;
28              
29             =head1 DESCRIPTION
30              
31             This fills the map with random seed regions which then grow to fill the map.
32              
33             Settlements are placed at random.
34              
35             Every mountain region is the source of a river. Rivers flow through regions that
36             are not themselves mountains or a deserts. Rivers end in swamps.
37              
38             =cut
39              
40             package Game::TextMapper::Apocalypse;
41 11     11   103 use Game::TextMapper::Log;
  11         35  
  11         523  
42 11     11   67 use Modern::Perl '2018';
  11         22  
  11         95  
43 11     11   4191 use List::Util qw(shuffle any none);
  11         31  
  11         1458  
44 11     11   86 use Mojo::Base -base;
  11         39  
  11         116  
45              
46             my $log = Game::TextMapper::Log->get;
47              
48             =head1 ATTRIBUTES
49              
50             =head2 rows
51              
52             The height of the map, defaults to 10.
53              
54             use Modern::Perl;
55             use Game::TextMapper::Apocalypse;
56             my $map = Game::TextMapper::Apocalypse->new(rows => 20)
57             ->generate_map;
58             print $map;
59              
60             =head2 cols
61              
62             The width of the map, defaults to 20.
63              
64             use Modern::Perl;
65             use Game::TextMapper::Apocalypse;
66             my $map = Game::TextMapper::Apocalypse->new(cols => 30)
67             ->generate_map;
68             print $map;
69              
70             =head2 region_size
71              
72             The size of regions sharing the same terrain type, on average, defaults to 5
73             hexes. The algorithm computes the number of hexes, divides it by the region size,
74             and that's the number of seeds it starts with (C × C ÷
75             C).
76              
77             use Modern::Perl;
78             use Game::TextMapper::Apocalypse;
79             my $map = Game::TextMapper::Apocalypse->new(region_size => 3)
80             ->generate_map;
81             print $map;
82              
83             =head2 settlement_chance
84              
85             The chance of a hex containing a settlement, from 0 to 1, defaults to 0.1 (10%).
86              
87             use Modern::Perl;
88             use Game::TextMapper::Apocalypse;
89             my $map = Game::TextMapper::Apocalypse->new(settlement_chance => 0.2)
90             ->generate_map;
91             print $map;
92              
93             =head2 loglevel
94              
95             By default, the log level is set by L from the config file. If
96             you use the generator on its own, however, the log defaults to log level
97             "debug". You might want to change that. The options are "error", "warn", "info"
98             and "debug".
99              
100             use Modern::Perl;
101             use Game::TextMapper::Apocalypse;
102             my $map = Game::TextMapper::Apocalypse->new(loglevel => 'error')
103             ->generate_map;
104             print $map;
105              
106             =cut
107              
108             has 'rows' => 10;
109             has 'cols' => 20;
110             has 'region_size' => 5;
111             has 'settlement_chance' => 0.1;
112             has 'loglevel';
113              
114             my @tiles = qw(forest desert mountain jungle swamp grass);
115             my @settlements = qw(ruin fort cave);
116              
117             =head1 METHODS
118              
119             =head2 generate_map
120              
121             This method takes no arguments. Set the properties of the map using the
122             attributes.
123              
124             =cut
125              
126             sub generate_map {
127 2     2 1 15 my $self = shift;
128 2 50       12 $log->level($self->loglevel) if $self->loglevel;
129 2         47 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 2         80 my $seeds = $self->rows * $self->cols / $self->region_size;
131 2         62 my $tiles = [];
132 2         114 $tiles->[$_] = [$tiles[int(rand(@tiles))]] for splice(@coordinates, 0, $seeds);
133 2         15 $tiles->[$_] = [$self->close_to($_, $tiles)] for @coordinates;
134             # warn "$_\n" for $self->neighbours(0);
135             # push(@{$tiles->[$_]}, "red") for map { $self->neighbours($_) } 70, 75;
136             # push(@{$tiles->[$_]}, "red") for map { $self->neighbours($_) } 3, 8, 60, 120;
137             # push(@{$tiles->[$_]}, "red") for map { $self->neighbours($_) } 187, 194, 39, 139;
138             # push(@{$tiles->[$_]}, "red") for map { $self->neighbours($_) } 0, 19, 180, 199;
139             # push(@{$tiles->[$_]}, "red") for map { $self->neighbours($_) } 161;
140 2         8 for my $tile (@$tiles) {
141 400 100       2157 push(@$tile, $settlements[int(rand(@settlements))]) if rand() < $self->settlement_chance;
142             }
143 2         23 my $rivers = $self->rivers($tiles);
144 2         9 return $self->to_text($tiles, $rivers);
145             }
146              
147             sub neighbours {
148 496     496 0 854 my $self = shift;
149 496         835 my $coordinate = shift;
150 496         831 my @offsets;
151 496 100       1233 if ($coordinate % 2) {
152 244         641 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 244 100       3102 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 244 100       1380 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 244 50       1956 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 244 100       1438 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 252         642 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 252 100       3383 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 252 100       1387 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 252 100       2032 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 252 50       1390 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
163             }
164             # die "@offsets" if any { $coordinate + $_ < 0 or $coordinate + $_ >= $self->cols * $self->rows } @offsets;
165 496         3880 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  2694         5500  
  2976         6475  
166             }
167              
168             sub close_to {
169 320     320 0 571 my $self = shift;
170 320         542 my $coordinate = shift;
171 320         518 my $tiles = shift;
172 320         782 for ($self->neighbours($coordinate)) {
173 571 100       2324 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 9         183 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 2     2 0 9 my $self = shift;
180 2         5 my $tiles = shift;
181             # the array of rivers has a cell for each coordinate: if there are no rivers,
182             # it is undef; else it is a reference to the river
183 2         7 my $rivers = [];
184 2         11 for my $source (grep { $self->is_source($_, $tiles) } 0 .. $self->rows * $self->cols - 1) {
  400         906  
185 48         137 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  48         795  
186 48         462 my $river = [$source];
187 48         139 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 2         38 return $rivers;
190             }
191              
192             sub grow_river {
193 176     176 0 334 my $self = shift;
194 176         285 my $coordinate = shift;
195 176         268 my $river = shift;
196 176         319 my $rivers = shift;
197 176         292 my $tiles = shift;
198 176         450 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  960         2202  
199 176 100       495 return unless @destinations; # this is a dead end
200 158         355 for my $next (@destinations) {
201 166         401 push(@$river, $next);
202 166         425 $log->debug(" " . $self->xy($river));
203 166 100       3502 if ($rivers->[$next]) {
    100          
204 20         71 $log->debug(" merge!");
205 20         130 my @other = @{$rivers->[$next]};
  20         128  
206 20         62 while ($other[0] != $next) { shift @other };
  51         144  
207 20         34 shift @other; # get rid of the duplicate $next
208 20         98 push(@$river, @other);
209 20         55 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 18         93 $log->debug(" done!");
212 18         146 return $self->mark_river($river, $rivers);
213             } else {
214 128         512 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 128 100       531 return $result if $result;
216 14         54 $log->debug(" dead end!");
217 14         114 $rivers->[$next] = 0; # prevents this from being a destination
218 14         59 pop(@$river);
219             }
220             }
221 6         16 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 38     38 0 68 my $self = shift;
226 38         78 my $river = shift;
227 38         60 my $rivers = shift;
228 38         119 for my $coordinate (@$river) {
229 355         683 $rivers->[$coordinate] = $river;
230             }
231 38         162 return $river;
232             }
233              
234             sub is_source {
235 400     400 0 667 my $self = shift;
236 400         675 my $coordinate = shift;
237 400         630 my $tiles = shift;
238 400     437   870 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  437         1352  
  400         935  
239             }
240              
241             sub is_destination {
242 960     960 0 1556 my $self = shift;
243 960         1560 my $coordinate = shift;
244 960         1524 my $river = shift;
245 960         1615 my $rivers = shift;
246 960         1604 my $tiles = shift;
247 960 100 100     2582 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 959 100       1836 return 0 if grep { $_ == $coordinate } @$river;
  6436         13050  
249 749 100   803   2259 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  803         4524  
  749         2057  
250             }
251              
252             sub is_sink {
253 146     146 0 296 my $self = shift;
254 146         258 my $coordinate = shift;
255 146         238 my $tiles = shift;
256 146     164   482 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  164         528  
  146         470  
257             }
258              
259             sub to_text {
260 2     2 0 7 my $self = shift;
261 2         5 my $tiles = shift;
262 2         5 my $rivers = shift;
263 2         6 my $text = "";
264 2         11 for my $i (0 .. $self->rows * $self->cols - 1) {
265 400 50       1001 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  400         3421  
266             }
267 2         14 for my $river (@$rivers) {
268 384 100 66     3221 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 2         39 $text .= "\ninclude apocalypse.txt\n";
271 2         474 return $text;
272             }
273              
274             sub xy {
275 784     784 0 1304 my $self = shift;
276 784 50       1693 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 784 100       1958 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 336         570 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  3512         27452  
  336         739  
279             }
280              
281             1;