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   83 use Game::TextMapper::Log;
  11         25  
  11         509  
42 11     11   63 use Modern::Perl '2018';
  11         20  
  11         76  
43 11     11   3477 use List::Util qw(shuffle any none);
  11         28  
  11         1036  
44 11     11   66 use Mojo::Base -base;
  11         19  
  11         80  
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 32 my $self = shift;
128 2 50       12 $log->level($self->loglevel) if $self->loglevel;
129 2         29 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 2         76 my $seeds = $self->rows * $self->cols / $self->region_size;
131 2         26 my $tiles = [];
132 2         89 $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       1598 push(@$tile, $settlements[int(rand(@settlements))]) if rand() < $self->settlement_chance;
142             }
143 2         16 my $rivers = $self->rivers($tiles);
144 2         8 return $self->to_text($tiles, $rivers);
145             }
146              
147             sub neighbours {
148 571     571 0 752 my $self = shift;
149 571         703 my $coordinate = shift;
150 571         619 my @offsets;
151 571 100       910 if ($coordinate % 2) {
152 290         545 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 290 100       2496 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 290 100       1220 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 290 50       1572 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 290 100       1032 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 281         492 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 281 100       2425 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 281 100       1027 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 281 100       1451 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 281 50       1043 $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 571         3023 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  3101         4435  
  3426         5067  
166             }
167              
168             sub close_to {
169 320     320 0 493 my $self = shift;
170 320         390 my $coordinate = shift;
171 320         427 my $tiles = shift;
172 320         614 for ($self->neighbours($coordinate)) {
173 596 100       1894 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 15         131 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 2     2 0 7 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         9 for my $source (grep { $self->is_source($_, $tiles) } 0 .. $self->rows * $self->cols - 1) {
  400         698  
185 105         173 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  105         759  
186 105         475 my $river = [$source];
187 105         184 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 2         18 return $rivers;
190             }
191              
192             sub grow_river {
193 251     251 0 300 my $self = shift;
194 251         268 my $coordinate = shift;
195 251         263 my $river = shift;
196 251         276 my $rivers = shift;
197 251         261 my $tiles = shift;
198 251         414 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  1374         2009  
199 251 100       471 return unless @destinations; # this is a dead end
200 206         307 for my $next (@destinations) {
201 226         350 push(@$river, $next);
202 226         392 $log->debug(" " . $self->xy($river));
203 226 100       2636 if ($rivers->[$next]) {
    100          
204 54         96 $log->debug(" merge!");
205 54         181 my @other = @{$rivers->[$next]};
  54         125  
206 54         85 while ($other[0] != $next) { shift @other };
  140         179  
207 54         61 shift @other; # get rid of the duplicate $next
208 54         118 push(@$river, @other);
209 54         105 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 26         49 $log->debug(" done!");
212 26         106 return $self->mark_river($river, $rivers);
213             } else {
214 146         337 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 146 100       354 return $result if $result;
216 37         71 $log->debug(" dead end!");
217 37         165 $rivers->[$next] = 0; # prevents this from being a destination
218 37         74 pop(@$river);
219             }
220             }
221 17         29 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 80     80 0 87 my $self = shift;
226 80         94 my $river = shift;
227 80         85 my $rivers = shift;
228 80         100 for my $coordinate (@$river) {
229 536         623 $rivers->[$coordinate] = $river;
230             }
231 80         179 return $river;
232             }
233              
234             sub is_source {
235 400     400 0 505 my $self = shift;
236 400         576 my $coordinate = shift;
237 400         467 my $tiles = shift;
238 400     430   697 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  430         970  
  400         770  
239             }
240              
241             sub is_destination {
242 1374     1374 0 1481 my $self = shift;
243 1374         1454 my $coordinate = shift;
244 1374         1363 my $river = shift;
245 1374         1335 my $rivers = shift;
246 1374         1308 my $tiles = shift;
247 1374 100 100     2240 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 1323 100       1578 return 0 if grep { $_ == $coordinate } @$river;
  6060         7918  
249 1083 100   1139   1949 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  1139         3440  
  1083         1814  
250             }
251              
252             sub is_sink {
253 172     172 0 227 my $self = shift;
254 172         193 my $coordinate = shift;
255 172         201 my $tiles = shift;
256 172     186   373 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  186         381  
  172         348  
257             }
258              
259             sub to_text {
260 2     2 0 3 my $self = shift;
261 2         4 my $tiles = shift;
262 2         2 my $rivers = shift;
263 2         6 my $text = "";
264 2         7 for my $i (0 .. $self->rows * $self->cols - 1) {
265 400 50       595 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  400         1896  
266             }
267 2         11 for my $river (@$rivers) {
268 400 100 66     1739 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 2         9 $text .= "\ninclude apocalypse.txt\n";
271 2         254 return $text;
272             }
273              
274             sub xy {
275 946     946 0 989 my $self = shift;
276 946 50       1227 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 946 100       1367 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 441         450 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  3169         13631  
  441         551  
279             }
280              
281             1;