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   8 use Game::TextMapper::Log;
  1         2  
  1         30  
42 1     1   5 use Modern::Perl '2018';
  1         2  
  1         10  
43 1     1   206 use List::Util qw(shuffle any none);
  1         3  
  1         97  
44 1     1   6 use Mojo::Base -base;
  1         2  
  1         9  
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 11 my $self = shift;
128 1 50       7 $log->level($self->loglevel) if $self->loglevel;
129 1         18 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 1         34 my $seeds = $self->rows * $self->cols / $self->region_size;
131 1         14 my $tiles = [];
132 1         43 $tiles->[$_] = [$tiles[int(rand(@tiles))]] for splice(@coordinates, 0, $seeds);
133 1         9 $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         6 for my $tile (@$tiles) {
141 200 100       770 push(@$tile, $settlements[int(rand(@settlements))]) if rand() < $self->settlement_chance;
142             }
143 1         9 my $rivers = $self->rivers($tiles);
144 1         5 return $self->to_text($tiles, $rivers);
145             }
146              
147             sub neighbours {
148 278     278 0 321 my $self = shift;
149 278         312 my $coordinate = shift;
150 278         297 my @offsets;
151 278 100       404 if ($coordinate % 2) {
152 143         261 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 143 100       1331 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 143 100       527 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 143 50       804 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 143 100       501 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 135         244 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 135 100       1121 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 135 100       599 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 135 100       704 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 135 50       486 $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 278         1365 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  1510         2008  
  1668         2016  
166             }
167              
168             sub close_to {
169 160     160 0 204 my $self = shift;
170 160         170 my $coordinate = shift;
171 160         163 my $tiles = shift;
172 160         238 for ($self->neighbours($coordinate)) {
173 281 100       733 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 6         25 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 1     1 0 4 my $self = shift;
180 1         3 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         2 my $rivers = [];
184 1         9 for my $source (grep { $self->is_source($_, $tiles) } 0 .. $self->rows * $self->cols - 1) {
  200         291  
185 39         78 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  39         424  
186 39         241 my $river = [$source];
187 39         65 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 1         11 return $rivers;
190             }
191              
192             sub grow_river {
193 118     118 0 213 my $self = shift;
194 118         146 my $coordinate = shift;
195 118         150 my $river = shift;
196 118         133 my $rivers = shift;
197 118         133 my $tiles = shift;
198 118         206 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  634         965  
199 118 100       237 return unless @destinations; # this is a dead end
200 96         136 for my $next (@destinations) {
201 106         184 push(@$river, $next);
202 106         167 $log->debug(" " . $self->xy($river));
203 106 100       1436 if ($rivers->[$next]) {
    100          
204 18         45 $log->debug(" merge!");
205 18         84 my @other = @{$rivers->[$next]};
  18         73  
206 18         47 while ($other[0] != $next) { shift @other };
  62         100  
207 18         25 shift @other; # get rid of the duplicate $next
208 18         74 push(@$river, @other);
209 18         41 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 9         26 $log->debug(" done!");
212 9         55 return $self->mark_river($river, $rivers);
213             } else {
214 79         215 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 79 100       238 return $result if $result;
216 22         55 $log->debug(" dead end!");
217 22         120 $rivers->[$next] = 0; # prevents this from being a destination
218 22         59 pop(@$river);
219             }
220             }
221 12         26 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 27     27 0 35 my $self = shift;
226 27         39 my $river = shift;
227 27         30 my $rivers = shift;
228 27         50 for my $coordinate (@$river) {
229 376         442 $rivers->[$coordinate] = $river;
230             }
231 27         140 return $river;
232             }
233              
234             sub is_source {
235 200     200 0 215 my $self = shift;
236 200         205 my $coordinate = shift;
237 200         210 my $tiles = shift;
238 200     216   330 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  216         448  
  200         338  
239             }
240              
241             sub is_destination {
242 634     634 0 731 my $self = shift;
243 634         669 my $coordinate = shift;
244 634         671 my $river = shift;
245 634         644 my $rivers = shift;
246 634         669 my $tiles = shift;
247 634 100 100     1210 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 617 100       781 return 0 if grep { $_ == $coordinate } @$river;
  4333         5975  
249 478 100   496   955 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  496         1714  
  478         920  
250             }
251              
252             sub is_sink {
253 88     88 0 139 my $self = shift;
254 88         104 my $coordinate = shift;
255 88         108 my $tiles = shift;
256 88     94   195 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  94         197  
  88         192  
257             }
258              
259             sub to_text {
260 1     1 0 3 my $self = shift;
261 1         3 my $tiles = shift;
262 1         2 my $rivers = shift;
263 1         5 my $text = "";
264 1         3 for my $i (0 .. $self->rows * $self->cols - 1) {
265 200 50       393 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  200         1449  
266             }
267 1         11 for my $river (@$rivers) {
268 200 100 66     1268 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 1         6 $text .= "\ninclude apocalypse.txt\n";
271 1         133 return $text;
272             }
273              
274             sub xy {
275 438     438 0 539 my $self = shift;
276 438 50       652 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 438 100       789 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 199         245 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  3047         17548  
  199         292  
279             }
280              
281             1;