File Coverage

blib/lib/Game/TextMapper/Solo.pm
Criterion Covered Total %
statement 12 226 5.3
branch 0 160 0.0
condition 0 54 0.0
subroutine 4 23 17.3
pod 1 15 6.6
total 17 478 3.5


line stmt bran cond sub pod time code
1             # Copyright (C) 2024 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::Solo - generate a map generated step by step
21              
22             =head1 SYNOPSIS
23              
24             use Modern::Perl;
25             use Game::TextMapper::Solo;
26             my $map = Game::TextMapper::Solo->new->generate_map();
27             print $map;
28              
29             =head1 DESCRIPTION
30              
31             This starts the map and generates all the details directly, for each step,
32             without knowledge of the rest of the map. The tricky part is to generate
33             features such that no terrible geographical problems arise.
34              
35             =cut
36              
37             package Game::TextMapper::Solo;
38 11     11   95 use Game::TextMapper::Log;
  11         26  
  11         523  
39 11     11   65 use Modern::Perl '2018';
  11         28  
  11         108  
40 11     11   4522 use List::Util qw(shuffle all any none);
  11         33  
  11         1348  
41 11     11   77 use Mojo::Base -base;
  11         26  
  11         106  
42              
43             my $log = Game::TextMapper::Log->get;
44              
45             =head1 ATTRIBUTES
46              
47             =head2 rows
48              
49             The height of the map, defaults to 15.
50              
51             use Modern::Perl;
52             use Game::TextMapper::Solo;
53             my $map = Game::TextMapper::Solo->new(rows => 20)
54             ->generate_map;
55             print $map;
56              
57             =head2 cols
58              
59             The width of the map, defaults to 30.
60              
61             use Modern::Perl;
62             use Game::TextMapper::Solo;
63             my $map = Game::TextMapper::Solo->new(cols => 40)
64             ->generate_map;
65             print $map;
66              
67             =cut
68              
69             has 'rows' => 15;
70             has 'cols' => 30;
71             has 'altitudes' => sub{[]}; # these are the altitudes of each hex, a number between 0 (deep ocean) and 10 (ice)
72             has 'tiles' => sub{[]}; # these are the tiles on the map, an array of arrays of strings
73             has 'flows' => sub{[]}; # these are the water flow directions on the map, an array of coordinates
74             has 'rivers' => sub{[]}; # for rendering, the flows are turned into rivers, an array of arrays of coordinates
75             has 'trails' => sub{[]};
76             has 'loglevel';
77              
78             my @tiles = qw(plain rough swamp desert forest hills green-hills forest-hill mountains mountain volcano ice water coastal ocean);
79             my @no_sources = qw(desert volcano water coastal ocean);
80             my @settlements = qw(house ruin tower ruined-tower castle ruined-castle);
81             my @ruins = qw(ruin ruined-tower ruined-castle);
82              
83             =head1 METHODS
84              
85             =head2 generate_map
86              
87             This method takes no arguments. Set the properties of the map using the
88             attributes.
89              
90             =cut
91              
92             sub generate_map {
93 0     0 1   my ($self) = @_;
94 0 0         $log->level($self->loglevel) if $self->loglevel;
95 0           $self->random_walk();
96             # my $walks = $self->random_walk();
97             # debug random walks
98             # my @walks = @$walks;
99             # @walks = @walks[0 .. 10];
100             # $self->trails(\@walks);
101 0           $self->add_rivers();
102 0           return $self->to_text();
103             }
104              
105             sub random_walk {
106 0     0 0   my ($self) = @_;
107 0           my %seen;
108 0           my $tile_count = 0;
109 0           my $path_length = 1;
110 0           my $max_tiles = $self->rows * $self->cols;
111 0           my $start = int($self->rows / 2) * $self->cols + int($self->cols / 2);
112 0           $self->altitudes->[$start] = 5;
113 0           my @neighbours = $self->neighbours($start);
114             # initial river setup: roll a d6 four destination
115 0           $self->flows->[$start] = $neighbours[int(rand(6))];
116             # roll a d6 for source, skip if same as destination
117 0           my $source = $neighbours[int(rand(6))];
118 0 0         $self->flows->[$source] = $start unless $source == $self->flows->[$start];
119             # initial setup: roll for starting region with a village
120 0           $seen{$start} = 1;
121 0           $self->random_tile($start, $start, 'house');
122 0 0         push(@{$self->tiles->[$start]}, qq("$tile_count/$start")) if $log->level eq 'debug';
  0            
123 0           $tile_count++;
124             # roll for the immediate neighbours
125 0           for my $to (@neighbours) {
126 0           $seen{$to} = 1;
127 0           $self->random_tile($start, $to);
128 0 0         push(@{$self->tiles->[$to]}, qq("$tile_count/$to")) if $log->level eq 'debug';
  0            
129 0           $tile_count++;
130             }
131             # remember those walks for debugging (assign to trails, for example)
132 0           my $walks = [];
133             # while there are still undiscovered hexes
134 0           while ($tile_count < $max_tiles) {
135             # create an expedition of length l
136 0           my $from = $start;
137 0           my $to = $start;
138 0           my $walk = [];
139 0           for (my $i = 0; $i < $path_length; $i++) {
140 0           push(@$walk, $to);
141 0 0         if (not $seen{$to}) {
142 0           $seen{$to} = 1;
143 0           $self->random_tile($from, $to);
144 0 0         push(@{$self->tiles->[$to]}, qq("$tile_count/$to")) if $log->level eq 'debug';
  0            
145 0           $tile_count++;
146             }
147 0           $from = $to;
148 0           $to = $self->neighbour($from, \%seen);
149             }
150 0           $path_length++;
151 0           push(@$walks, $walk);
152             # last if @$walks > 10;
153             }
154 0           return $walks;
155             }
156              
157             sub random_tile {
158 0     0 0   my ($self, $from, $to, $settlement) = @_;
159 0           my $roll = roll_2d6();
160 0           my $altitude = $self->adjust_altitude($roll, $from, $to);
161             # coastal water always has flow
162 0   0       $self->add_flow($to, ($roll >= 5 and $roll <= 8 or $altitude == 1));
163 0           my $wet = defined $self->flows->[$to];
164 0           my $tile;
165 0 0         if ($altitude == 0) { $tile = 'ocean' }
  0 0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
166 0           elsif ($altitude == 1) { $tile = 'coastal' }
167 0 0         elsif ($altitude == 2) { $tile = $wet ? 'swamp' : 'desert' }
168 0 0         elsif ($altitude == 3) { $tile = $wet ? 'swamp' : 'plain' }
169 0 0         elsif ($altitude == 4) { $tile = $wet ? 'forest' : 'plain' }
170 0 0         elsif ($altitude == 5) { $tile = $wet ? 'forest' : 'plain' }
171 0 0         elsif ($altitude == 6) { $tile = $wet ? 'forest-hill' : 'rough' }
172 0 0         elsif ($altitude == 7) { $tile = $wet ? 'green-hills' : 'hills' }
173 0           elsif ($altitude == 8) { $tile = 'mountains' }
174 0 0         elsif ($altitude == 9) { $tile = special() ? 'volcano' : 'mountain' }
175 0           else { $tile = 'ice' }
176 0           push(@{$self->tiles->[$to]}, $tile);
  0            
177 0 0         if ($settlement) {
    0          
178 0           push(@{$self->tiles->[$to]}, $settlement);
  0            
179             } elsif ($roll == 7) {
180 0 0 0       if ($tile eq 'forest' or $tile eq 'forest-hill') {
    0 0        
      0        
181 0           push(@{$self->tiles->[$to]}, $settlements[int(rand($#settlements + 1))]);
  0            
182             } elsif ($tile eq 'desert' or $tile eq 'swamp' or $tile eq 'green-hills') {
183 0           push(@{$self->tiles->[$to]}, $ruins[int(rand($#ruins + 1))]);
  0            
184             }
185             }
186 0 0         push(@{$self->tiles->[$to]}, qq("+$altitude")) if $log->level eq 'debug';
  0            
187             }
188              
189             sub adjust_altitude {
190 0     0 0   my ($self, $roll, $from, $to) = @_;
191 0           my @neighbours = $self->neighbours($to);
192             # ocean stays ocean
193 0 0   0     if (all { defined $self->altitudes->[$_] and $self->altitudes->[$_] <= 1 } @neighbours) {
  0 0          
194 0           return $self->altitudes->[$to] = 0;
195             }
196 0           my $altitude = $self->altitudes->[$from];
197 0           my $max = 10;
198             # if we're following a river, the altitude rarely goes up; neighbouring hexes
199             # also limit the heigh changes
200 0           for (@neighbours) {
201 0 0 0       if (defined $self->flows->[$_]
      0        
      0        
202             and $self->flows->[$_] == $to
203             and defined $self->altitudes->[$_]
204             and $self->altitudes->[$_] < $max) {
205 0           $max = $self->altitudes->[$_];
206             }
207             }
208 0           my $delta = 0;
209 0 0         if ($roll == 2) { $delta = -2 }
  0 0          
    0          
    0          
    0          
210 0           elsif ($roll == 3) { $delta = -1 }
211 0           elsif ($roll == 10) { $delta = +1 }
212 0           elsif ($roll == 11) { $delta = +1 }
213 0           elsif ($roll == 12) { $delta = +2 }
214 0           $altitude += $delta;
215 0 0         $altitude = $max if $altitude > $max;
216 0 0         $altitude = 0 if $altitude < 0;
217 0 0 0 0     $altitude = 1 if $altitude == 0 and any { defined $self->altitudes->[$_] and $self->altitudes->[$_] > 1 } @neighbours;
  0 0          
218 0           return $self->altitudes->[$to] = $altitude;
219             }
220              
221             sub add_flow {
222 0     0 0   my ($self, $to, $source) = @_;
223 0           my @neighbours = $self->neighbours($to);
224             # don't do anything if there's already water flow
225 0 0         return if defined $self->flows->[$to];
226             # don't do anything if this is ocean
227 0 0 0       return if defined $self->altitudes->[$to] and $self->altitudes->[$to] == 0;
228             # if this hex can be a source or water from a neighbour flows into it
229 0 0 0       if ($source and not $self->tiles->[$to] and $self->altitudes->[$to] >= 1 and $self->altitudes->[$to] <= 8
      0        
      0        
      0        
230 0 0   0     or any { defined $self->flows->[$_] and $self->flows->[$_] == $to } @neighbours) {
231             # prefer a lower neighbour (or an undefined one), but "lower" works only for
232             # known hexes so there must already be water flow, there, and that water
233             # flow must not be circular
234             my @candidates = grep {
235 0 0 0       not defined $self->altitudes->[$_]
  0            
236             or $self->altitudes->[$_] < $self->altitudes->[$to]
237             and $self->flowable($to, $_)
238             } @neighbours;
239 0 0         if (@candidates) {
240 0           $self->flows->[$to] = $candidates[0];
241 0           return;
242             }
243             # or if this hex is at the edge, prefer flowing off the edge of the map
244 0 0         if (@neighbours < 6) {
245 0           $self->flows->[$to] = -1;
246 0           return;
247             }
248             # or prefer of equal altitude but again this works only for known hexes so
249             # there must already be water flow, there, and that water flow must not be
250             # circular
251             @candidates = grep {
252 0 0         $self->altitudes->[$_] == $self->altitudes->[$to]
  0            
253             and $self->flowable($to, $_)
254             } @neighbours;
255 0 0         if (@candidates) {
256 0           $self->flows->[$to] = $candidates[0];
257 0           return;
258             }
259             # or it's magic!!
260 0           @candidates = grep { $self->flowable($to, $_) } @neighbours;
  0            
261 0 0         if (@candidates) {
262 0           $log->info("Awkward transition at " . $self->xy($to));
263 0           $self->flows->[$to] = $candidates[0];
264 0           return;
265             }
266             # Or it's a dead end… and entrance into the underworld, obviously
267 0 0         if ($self->altitudes->[$to] > 1) {
268 0           push(@{$self->tiles->[$to]}, 'cave');
  0            
269             }
270             }
271             }
272              
273             # A river can from A to B if B is undefined or if B has flow that doesn't return
274             # to A.
275             sub flowable {
276 0     0 0   my ($self, $from, $to) = @_;
277 0           my $flow = 0;
278 0   0       while (defined $self->flows->[$to] and $self->flows->[$to] >= 0) {
279 0           $to = $self->flows->[$to];
280 0 0         return 0 if $to == $from;
281 0           $flow = 1;
282             }
283 0           return $flow;
284             }
285              
286             sub add_rivers {
287 0     0 0   my ($self) = @_;
288 0           my %seen;
289 0           for my $coordinate (0 .. $self->rows * $self->cols - 1) {
290 0 0         next unless defined $self->flows->[$coordinate];
291 0 0         next if $self->altitudes->[$coordinate] <= 1; # do not show rivers starting here
292 0 0         next if $seen{$coordinate};
293 0           $seen{$coordinate} = 1;
294 0 0         if (none {
295 0 0   0     defined $self->flows->[$_]
296             and $self->flows->[$_] == $coordinate
297             } $self->neighbours($coordinate)) {
298 0           my $river = [];
299 0           while (defined $coordinate) {
300 0           push(@$river, $coordinate);
301 0 0         last if $coordinate == -1;
302 0           $seen{$coordinate} = 1;
303 0           $coordinate = $self->flows->[$coordinate];
304             }
305 0           push(@{$self->rivers}, $river);
  0            
306             }
307             }
308             }
309              
310             sub special {
311 0     0 0   return rand() < 1/6;
312             }
313              
314             sub roll_2d6 {
315 0     0 0   return 2 + int(rand(6)) + int(rand(6));
316             }
317              
318             sub neighbour {
319 0     0 0   my ($self, $coordinate, $seen) = @_;
320 0           my @neighbours = $self->neighbours($coordinate);
321             # If a seen hash reference is provided, prefer new hexes
322 0 0         if ($seen) {
323 0           my @candidates = grep {!($seen->{$_})} @neighbours;
  0            
324 0 0         return $candidates[0] if @candidates;
325             }
326 0           return $neighbours[0];
327             }
328              
329             # Returns the coordinates of neighbour regions, in random order, but only if on
330             # the map.
331             sub neighbours {
332 0     0 0   my ($self, $coordinate) = @_;
333 0           my @offsets;
334 0 0         if ($coordinate % 2) {
335 0           @offsets = (-1, +1, $self->cols, -$self->cols, $self->cols -1, $self->cols +1);
336 0 0         $offsets[3] = undef if $coordinate < $self->cols; # top edge
337 0 0         $offsets[2] = $offsets[4] = $offsets[5] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
338 0 0         $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
339 0 0         $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
340             } else {
341 0           @offsets = (-1, +1, $self->cols, -$self->cols, -$self->cols -1, -$self->cols +1);
342 0 0         $offsets[3] = $offsets[4] = $offsets[5] = undef if $coordinate < $self->cols; # top edge
343 0 0         $offsets[2] = undef if $coordinate >= ($self->rows - 1) * $self->cols; # bottom edge
344 0 0         $offsets[0] = $offsets[4] = undef if $coordinate % $self->cols == 0; # left edge
345 0 0         $offsets[1] = $offsets[5] = undef if $coordinate % $self->cols == $self->cols - 1; # right edge
346             }
347 0           return map { $coordinate + $_ } shuffle grep {$_} @offsets;
  0            
  0            
348             }
349              
350             # Return the direction of a neighbour given its coordinates. 0 is up (north), 1
351             # is north-east, 2 is south-east, 3 is south, 4 is south-west, 5 is north-west.
352             sub direction {
353 0     0 0   my ($self, $from, $to) = @_;
354 0           my @offsets;
355 0 0         if ($from % 2) {
356 0           @offsets = (-$self->cols, +1, $self->cols +1, $self->cols, $self->cols -1, -1);
357             } else {
358 0           @offsets = (-$self->cols, -$self->cols +1, +1, $self->cols, -1, -$self->cols -1);
359             }
360 0           for (my $i = 0; $i < 6; $i++) {
361 0 0         return $i if $from + $offsets[$i] == $to;
362             }
363             }
364              
365             sub to_text {
366 0     0 0   my ($self) = @_;
367 0           my $text = "";
368 0           for my $i (0 .. $self->rows * $self->cols - 1) {
369 0 0         next unless $self->tiles->[$i];
370 0           my @tiles = @{$self->tiles->[$i]};
  0            
371 0 0 0       push(@tiles, "arrow" . $self->direction($i, $self->flows->[$i])) if defined $self->flows->[$i] and $log->level eq 'debug';
372 0           $text .= $self->xy($i) . " @tiles\n";
373             }
374 0           for my $river (@{$self->rivers}) {
  0            
375 0 0 0       $text .= $self->xy(@$river) . " river\n" if ref($river) and @$river > 1;
376             }
377 0           for my $trail (@{$self->trails}) {
  0            
378 0 0 0       $text .= $self->xy(@$trail) . " trails\n" if ref($trail) and @$trail > 1;
379             # More emphasis
380             # $text .= $self->xy(@$trail) . " border\n" if ref($trail) and @$trail > 1;
381             }
382             # add arrows for the flow
383             $text .= join("\n",
384             qq{},
385             map {
386 0           my $angle = 60 * $_;
  0            
387 0           qq{};
388             } (0 .. 5));
389 0           $text .= "\ninclude bright.txt\n";
390 0           return $text;
391             }
392              
393             sub xy {
394 0     0 0   my ($self, @coordinates) = @_;
395 0           for (my $i = 0; $i < @coordinates; $i++) {
396 0 0         if ($coordinates[$i] == -1) {
397 0           $coordinates[$i] = $self->edge($coordinates[$i - 1]);
398             } else {
399 0           $coordinates[$i] = sprintf("%02d%02d", $coordinates[$i] % $self->cols + 1, int($coordinates[$i] / $self->cols) + 1);
400             }
401             }
402 0           return join("-", @coordinates);
403             }
404              
405             sub edge {
406 0     0 0   my ($self, $coordinate) = @_;
407 0           my ($x, $y) = $coordinate =~ /(..)(..)/;
408 0 0         if ($x == 1) {
    0          
    0          
    0          
409 0           return "00" . $y;
410             } elsif ($x == $self->cols) {
411 0           return sprintf("%02d", $self->cols+1) . $y;
412             } elsif ($y == 1) {
413 0           return $x . "00";
414             } elsif ($y == $self->rows) {
415 0           return $x . sprintf("%02d", $self->rows+1);
416             }
417              
418             }
419              
420             1;