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 1     1   6 use Game::TextMapper::Log;
  1         2  
  1         27  
42 1     1   5 use Modern::Perl '2018';
  1         2  
  1         5  
43 1     1   115 use List::Util qw(shuffle any none);
  1         2  
  1         69  
44 1     1   4 use Mojo::Base -base;
  1         10  
  1         6  
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 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 1     1 1 10 my $self = shift;
128 1 50       5 $log->level($self->loglevel) if $self->loglevel;
129 1         12 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 1         32 my $seeds = $self->rows * $self->cols / $self->region_size;
131 1         11 my $tiles = [];
132 1         37 $tiles->[$_] = [$tiles[int(rand(@tiles))]] for splice(@coordinates, 0, $seeds);
133 1         7 $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 1         5 for my $tile (@$tiles) {
141 200 100       809 push(@$tile, $settlements[int(rand(@settlements))]) if rand() < $self->settlement_chance;
142             }
143 1         11 my $rivers = $self->rivers($tiles);
144 1         6 return $self->to_text($tiles, $rivers);
145             }
146              
147             sub neighbours {
148 284     284 0 337 my $self = shift;
149 284         309 my $coordinate = shift;
150 284         316 my @offsets;
151 284 100       433 if ($coordinate % 2) {
152 141         229 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 141 100       1151 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 141 100       562 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 141 50       792 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 141 100       539 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 143         219 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 143 100       1209 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 143 100       525 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 143 100       744 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 143 50       569 $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 284         1378 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  1538         1971  
  1704         2145  
166             }
167              
168             sub close_to {
169 160     160 0 205 my $self = shift;
170 160         173 my $coordinate = shift;
171 160         164 my $tiles = shift;
172 160         210 for ($self->neighbours($coordinate)) {
173 309 100       780 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 9         36 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 1     1 0 2 my $self = shift;
180 1         1 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 1         3 my $rivers = [];
184 1         4 for my $source (grep { $self->is_source($_, $tiles) } 0 .. $self->rows * $self->cols - 1) {
  200         292  
185 43         80 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  43         393  
186 43         227 my $river = [$source];
187 43         74 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 1         7 return $rivers;
190             }
191              
192             sub grow_river {
193 124     124 0 144 my $self = shift;
194 124         147 my $coordinate = shift;
195 124         141 my $river = shift;
196 124         139 my $rivers = shift;
197 124         129 my $tiles = shift;
198 124         195 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  676         1034  
199 124 100       260 return unless @destinations; # this is a dead end
200 107         166 for my $next (@destinations) {
201 113         193 push(@$river, $next);
202 113         188 $log->debug(" " . $self->xy($river));
203 113 100       1502 if ($rivers->[$next]) {
    100          
204 24         48 $log->debug(" merge!");
205 24         110 my @other = @{$rivers->[$next]};
  24         76  
206 24         47 while ($other[0] != $next) { shift @other };
  95         139  
207 24         25 shift @other; # get rid of the duplicate $next
208 24         67 push(@$river, @other);
209 24         40 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 8         23 $log->debug(" done!");
212 8         44 return $self->mark_river($river, $rivers);
213             } else {
214 81         192 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 81 100       194 return $result if $result;
216 7         20 $log->debug(" dead end!");
217 7         37 $rivers->[$next] = 0; # prevents this from being a destination
218 7         17 pop(@$river);
219             }
220             }
221 1         11 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 32     32 0 45 my $self = shift;
226 32         40 my $river = shift;
227 32         33 my $rivers = shift;
228 32         43 for my $coordinate (@$river) {
229 333         412 $rivers->[$coordinate] = $river;
230             }
231 32         75 return $river;
232             }
233              
234             sub is_source {
235 200     200 0 219 my $self = shift;
236 200         217 my $coordinate = shift;
237 200         208 my $tiles = shift;
238 200     222   326 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  222         456  
  200         321  
239             }
240              
241             sub is_destination {
242 676     676 0 775 my $self = shift;
243 676         715 my $coordinate = shift;
244 676         715 my $river = shift;
245 676         702 my $rivers = shift;
246 676         690 my $tiles = shift;
247 676 100 100     1235 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 670 100       1186 return 0 if grep { $_ == $coordinate } @$river;
  3237         4648  
249 537 100   581   1080 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  581         1923  
  537         887  
250             }
251              
252             sub is_sink {
253 89     89 0 123 my $self = shift;
254 89         104 my $coordinate = shift;
255 89         99 my $tiles = shift;
256 89     99   207 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  99         202  
  89         211  
257             }
258              
259             sub to_text {
260 1     1 0 3 my $self = shift;
261 1         6 my $tiles = shift;
262 1         2 my $rivers = shift;
263 1         2 my $text = "";
264 1         5 for my $i (0 .. $self->rows * $self->cols - 1) {
265 200 50       376 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  200         1340  
266             }
267 1         6 for my $river (@$rivers) {
268 196 100 66     1405 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 1         32 $text .= "\ninclude apocalypse.txt\n";
271 1         40 return $text;
272             }
273              
274             sub xy {
275 470     470 0 610 my $self = shift;
276 470 50       714 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 470 100       836 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 227         251 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  2309         13099  
  227         333  
279             }
280              
281             1;