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   4 use Modern::Perl '2018';
  1         2  
  1         7  
43 1     1   115 use List::Util qw(shuffle any none);
  1         1  
  1         78  
44 1     1   14 use Mojo::Base -base;
  1         2  
  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       7 $log->level($self->loglevel) if $self->loglevel;
129 1         14 my @coordinates = shuffle(0 .. $self->rows * $self->cols - 1);
130 1         24 my $seeds = $self->rows * $self->cols / $self->region_size;
131 1         13 my $tiles = [];
132 1         35 $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         3 for my $tile (@$tiles) {
141 200 100       737 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 288     288 0 334 my $self = shift;
149 288         345 my $coordinate = shift;
150 288         313 my @offsets;
151 288 100       409 if ($coordinate % 2) {
152 142         228 @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
153 142 100       1163 $offsets[3] = undef if $coordinate < $self->cols; # top edge
154 142 100       514 $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
155 142 50       730 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
156 142 100       537 $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
157             } else {
158 146         240 @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
159 146 100       1183 $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
160 146 100       550 $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
161 146 100       731 $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
162 146 50       519 $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 288         1390 return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  1560         1972  
  1728         2111  
166             }
167              
168             sub close_to {
169 160     160 0 193 my $self = shift;
170 160         173 my $coordinate = shift;
171 160         168 my $tiles = shift;
172 160         223 for ($self->neighbours($coordinate)) {
173 315 100       731 return $tiles->[$_]->[0] if $tiles->[$_];
174             }
175 8         30 return $tiles[int(rand(@tiles))];
176             }
177              
178             sub rivers {
179 1     1 0 3 my $self = shift;
180 1         2 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         285  
185 74         123 $log->debug("River starting at " . $self->xy($source) . " (@{$tiles->[$source]})");
  74         658  
186 74         408 my $river = [$source];
187 74         118 $self->grow_river($source, $river, $rivers, $tiles);
188             }
189 1         10 return $rivers;
190             }
191              
192             sub grow_river {
193 128     128 0 150 my $self = shift;
194 128         146 my $coordinate = shift;
195 128         151 my $river = shift;
196 128         133 my $rivers = shift;
197 128         165 my $tiles = shift;
198 128         204 my @destinations = shuffle grep { $self->is_destination($_, $river, $rivers, $tiles) } $self->neighbours($coordinate);
  699         1079  
199 128 100       271 return unless @destinations; # this is a dead end
200 100         146 for my $next (@destinations) {
201 102         153 push(@$river, $next);
202 102         163 $log->debug(" " . $self->xy($river));
203 102 100       1295 if ($rivers->[$next]) {
    100          
204 33         68 $log->debug(" merge!");
205 33         153 my @other = @{$rivers->[$next]};
  33         79  
206 33         63 while ($other[0] != $next) { shift @other };
  74         109  
207 33         48 shift @other; # get rid of the duplicate $next
208 33         70 push(@$river, @other);
209 33         55 return $self->mark_river($river, $rivers);
210             } elsif ($self->is_sink($next, $tiles)) {
211 15         36 $log->debug(" done!");
212 15         85 return $self->mark_river($river, $rivers);
213             } else {
214 54         123 my $result = $self->grow_river($next, $river, $rivers, $tiles);
215 54 100       128 return $result if $result;
216 4         13 $log->debug(" dead end!");
217 4         22 $rivers->[$next] = 0; # prevents this from being a destination
218 4         11 pop(@$river);
219             }
220             }
221 2         5 return; # all destinations were dead ends
222             }
223              
224             sub mark_river {
225 48     48 0 57 my $self = shift;
226 48         61 my $river = shift;
227 48         57 my $rivers = shift;
228 48         70 for my $coordinate (@$river) {
229 272         350 $rivers->[$coordinate] = $river;
230             }
231 48         108 return $river;
232             }
233              
234             sub is_source {
235 200     200 0 211 my $self = shift;
236 200         205 my $coordinate = shift;
237 200         213 my $tiles = shift;
238 200     218   319 return any { $_ eq 'mountain' } (@{$tiles->[$coordinate]});
  218         450  
  200         319  
239             }
240              
241             sub is_destination {
242 699     699 0 769 my $self = shift;
243 699         733 my $coordinate = shift;
244 699         717 my $river = shift;
245 699         730 my $rivers = shift;
246 699         728 my $tiles = shift;
247 699 100 100     1258 return 0 if defined $rivers->[$coordinate] and $rivers->[$coordinate] == 0;
248 695 100       874 return 0 if grep { $_ == $coordinate } @$river;
  1741         2677  
249 620 100   659   1162 return none { $_ eq 'mountain' or $_ eq 'desert' } (@{$tiles->[$coordinate]});
  659         2060  
  620         1057  
250             }
251              
252             sub is_sink {
253 69     69 0 85 my $self = shift;
254 69         77 my $coordinate = shift;
255 69         75 my $tiles = shift;
256 69     76   158 return any { $_ eq 'swamp' } (@{$tiles->[$coordinate]});
  76         162  
  69         134  
257             }
258              
259             sub to_text {
260 1     1 0 4 my $self = shift;
261 1         3 my $tiles = shift;
262 1         2 my $rivers = shift;
263 1         3 my $text = "";
264 1         3 for my $i (0 .. $self->rows * $self->cols - 1) {
265 200 50       381 $text .= $self->xy($i) . " @{$tiles->[$i]}\n" if $tiles->[$i];
  200         1359  
266             }
267 1         5 for my $river (@$rivers) {
268 200 100 66     1179 $text .= $self->xy($river) . " river\n" if ref($river) and @$river > 1;
269             }
270 1         4 $text .= "\ninclude apocalypse.txt\n";
271 1         38 return $text;
272             }
273              
274             sub xy {
275 489     489 0 574 my $self = shift;
276 489 50       737 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @_) if @_ > 1;
  0         0  
277 489 100       858 return sprintf("%02d%02d", $_[0] % $self->cols + 1, int($_[0] / $self->cols) + 1) unless ref($_[0]);
278 215         249 return join("-", map { sprintf("%02d%02d", $_ % $self->cols + 1, int($_ / $self->cols) + 1) } @{$_[0]});
  1168         6166  
  215         300  
279             }
280              
281             1;