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   76 use Game::TextMapper::Log;
  11         21  
  11         368  
42 11     11   43 use Modern::Perl '2018';
  11         17  
  11         81  
43 11     11   2389 use List::Util qw(shuffle any none);
  11         17  
  11         862  
44 11     11   47 use Mojo::Base -base;
  11         47  
  11         67  
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 50 my $self = shift;
128 2 50       11 $log->level($self->loglevel) if $self->loglevel;
129 2         27 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 2         70 my $seeds = $self->rows * $self->cols / $self->region_size;
131 2         22 my $tiles = [];
132 2         89 $tiles->[$_] = [$tiles[int(rand(@tiles))]] for splice(@coordinates, 0, $seeds);
133 2         14 $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         84 for my $tile (@$tiles) {
141 400 100       1437 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 486     486 0 568 my $self = shift;
149 486         567 my $coordinate = shift;
150 486         593 my @offsets;
151 486 100       802 if ($coordinate % 2) {
152 240         427 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 240 100       1955 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 240 100       901 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 240 50       1260 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 240 100       945 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 246         506 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 246 100       2133 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 246 100       1001 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 246 100       1343 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 246 50       985 $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 486         2637 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  2661         3903  
  2916         4671  
166             }
167              
168             sub close_to {
169 320     320 0 468 my $self = shift;
170 320         343 my $coordinate = shift;
171 320         417 my $tiles = shift;
172 320         519 for ($self->neighbours($coordinate)) {
173 560 100       1679 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 16         69 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 2     2 0 4 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         6 my $rivers = [];
184 2         9 for my $source (grep { $self->is_source($_, $tiles) } 0 .. $self->rows * $self->cols - 1) {
  400         629  
185 66         153 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  66         797  
186 66         446 my $river = [$source];
187 66         152 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 2         26 return $rivers;
190             }
191              
192             sub grow_river {
193 166     166 0 216 my $self = shift;
194 166         227 my $coordinate = shift;
195 166         214 my $river = shift;
196 166         231 my $rivers = shift;
197 166         211 my $tiles = shift;
198 166         323 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  921         1558  
199 166 100       345 return unless @destinations; # this is a dead end
200 139         238 for my $next (@destinations) {
201 150         255 push(@$river, $next);
202 150         302 $log->debug(" " . $self->xy($river));
203 150 100       2134 if ($rivers->[$next]) {
    100          
204 31         70 $log->debug(" merge!");
205 31         184 my @other = @{$rivers->[$next]};
  31         107  
206 31         67 while ($other[0] != $next) { shift @other };
  99         151  
207 31         39 shift @other; # get rid of the duplicate $next
208 31         87 push(@$river, @other);
209 31         64 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 19         58 $log->debug(" done!");
212 19         201 return $self->mark_river($river, $rivers);
213             } else {
214 100         238 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 100 100       254 return $result if $result;
216 20         72 $log->debug(" dead end!");
217 20         136 $rivers->[$next] = 0; # prevents this from being a destination
218 20         61 pop(@$river);
219             }
220             }
221 9         19 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 50     50 0 79 my $self = shift;
226 50         65 my $river = shift;
227 50         69 my $rivers = shift;
228 50         84 for my $coordinate (@$river) {
229 343         444 $rivers->[$coordinate] = $river;
230             }
231 50         211 return $river;
232             }
233              
234             sub is_source {
235 400     400 0 464 my $self = shift;
236 400         434 my $coordinate = shift;
237 400         409 my $tiles = shift;
238 400     422   622 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  422         925  
  400         693  
239             }
240              
241             sub is_destination {
242 921     921 0 1117 my $self = shift;
243 921         1100 my $coordinate = shift;
244 921         1089 my $river = shift;
245 921         1070 my $rivers = shift;
246 921         1029 my $tiles = shift;
247 921 100 100     1806 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 902 100       1188 return 0 if grep { $_ == $coordinate } @$river;
  5251         6773  
249 745 100   766   1590 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  766         2996  
  745         1611  
250             }
251              
252             sub is_sink {
253 119     119 0 191 my $self = shift;
254 119         146 my $coordinate = shift;
255 119         150 my $tiles = shift;
256 119     124   296 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  124         282  
  119         338  
257             }
258              
259             sub to_text {
260 2     2 0 5 my $self = shift;
261 2         5 my $tiles = shift;
262 2         3 my $rivers = shift;
263 2         5 my $text = "";
264 2         10 for my $i (0 .. $self->rows * $self->cols - 1) {
265 400 50       824 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  400         2627  
266             }
267 2         8 for my $river (@$rivers) {
268 386 100 66     1990 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 2         19 $text .= "\ninclude apocalypse.txt\n";
271 2         368 return $text;
272             }
273              
274             sub xy {
275 765     765 0 983 my $self = shift;
276 765 50       1269 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 765 100       1572 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 299         403 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  2607         12159  
  299         452  
279             }
280              
281             1;