File Coverage

blib/lib/Game/TextMapper/Mapper.pm
Criterion Covered Total %
statement 231 258 89.5
branch 63 88 71.5
condition 28 43 65.1
subroutine 22 22 100.0
pod 2 15 13.3
total 346 426 81.2


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::Mapper - a text map parser and builder
21              
22             =head1 SYNOPSIS
23              
24             use Modern::Perl;
25             use Game::TextMapper::Mapper::Hex;
26             my $map = <
27             0101 forest
28             include default.txt
29             EOT
30             my $svg = Game::TextMapper::Mapper::Hex->new(dist_dir => 'share')
31             ->initialize($map)
32             ->svg();
33             print $svg;
34              
35             =head1 DESCRIPTION
36              
37             This class knows how to parse a text containing a map description into SVG
38             definitions, and regions. Once the map is built, this class knows how to
39             generate the SVG for the entire map.
40              
41             The details depend on whether the map is a hex map or a square map. You should
42             use the appropriate class instead of this one: L
43             or L.
44              
45             =cut
46              
47             package Game::TextMapper::Mapper;
48 1     1   490 use Game::TextMapper::Log;
  1         2  
  1         26  
49 1     1   5 use Modern::Perl '2018';
  1         1  
  1         6  
50 1     1   103 use Mojo::UserAgent;
  1         2  
  1         11  
51 1     1   28 use Mojo::Base -base;
  1         1  
  1         3  
52 1     1   561 use File::Slurper qw(read_text);
  1         2740  
  1         48  
53 1     1   14 use Encode qw(decode_utf8);
  1         2  
  1         39  
54 1     1   454 use File::ShareDir 'dist_dir';
  1         22136  
  1         4399  
55              
56             =head1 ATTRIBUTES
57              
58             =head2 dist_dir
59              
60             You need to pass this during instantiation so that the mapper knows where to
61             find files it needs to include.
62              
63             =cut
64              
65             has 'local_files';
66             has 'dist_dir';
67             has 'map';
68             has 'regions' => sub { [] };
69             has 'attributes' => sub { {} };
70             has 'defs' => sub { [] };
71             has 'path' => sub { {} };
72             has 'lines' => sub { [] };
73             has 'things' => sub { [] };
74             has 'path_attributes' => sub { {} };
75             has 'text_attributes' => '';
76             has 'glow_attributes' => '';
77             has 'label_attributes' => '';
78             has 'messages' => sub { [] };
79             has 'seen' => sub { {} };
80             has 'license' => '';
81             has 'other' => sub { [] };
82             has 'url' => '';
83             has 'offset' => sub { [] };
84              
85             my $log = Game::TextMapper::Log->get;
86              
87             sub example {
88 1     1 0 5 return <<"EOT";
89             0101 mountain "mountain"
90             0102 swamp "swamp"
91             0103 hill "hill"
92             0104 forest "forest"
93             0201 empty pyramid "pyramid"
94             0202 tundra "tundra"
95             0203 coast "coast"
96             0204 empty house "house"
97             0301 woodland "woodland"
98             0302 wetland "wetland"
99             0303 plain "plain"
100             0304 sea "sea"
101             0401 hill tower "tower"
102             0402 sand house "house"
103             0403 jungle "jungle"
104             0501 mountain cave "cave"
105             0502 sand "sand"
106             0503 hill castle "castle"
107             0205-0103-0202-0303-0402 road
108             0101-0203 river
109             0401-0303-0403 border
110             include default.txt
111             license Public Domain
112             EOT
113             }
114              
115             =head1 METHODS
116              
117             =head2 initialize($map)
118              
119             Call this to load a map into the mapper.
120              
121             =cut
122              
123             sub initialize {
124 10     10 1 103 my ($self, $map) = @_;
125 10         89 $map =~ s/-/-/g; # -- are invalid in source comments...
126 10         55 $self->map($map);
127 10         1524 $self->process(split(/\r?\n/, $map));
128             }
129              
130             sub process {
131 18     18 0 3291 my $self = shift;
132 18         45 my $line_id = 0;
133 18         45 foreach (@_) {
134 3879 100       27128 if (/^(-?\d\d)(-?\d\d)(\d\d)?\s+(.*)/) {
    100          
    100          
    50          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
135 2502   50     8445 my $region = $self->make_region(x => $1, y => $2, z => $3||'00', map => $self);
136 2502         17456 my $rest = $4;
137 2502         6102 while (my ($tag, $label, $size) = $rest =~ /\b([a-z]+)=["“]([^"”]+)["”]\s*(\d+)?/) {
138 82 100       148 if ($tag eq 'name') {
139 41         88 $region->label($label);
140 41         201 $region->size($size);
141             }
142 82         1376 $rest =~ s/\b([a-z]+)=["“]([^"”]+)["”]\s*(\d+)?//;
143             }
144 2502         4814 while (my ($label, $size) = $rest =~ /["“]([^"”]+)["”]\s*(\d+)?/) {
145 6         21 $region->label($label);
146 6         78 $region->size($size);
147 6         54 $rest =~ s/["“]([^"”]+)["”]\s*(\d+)?//;
148             }
149 2502         6012 my @types = split(/\s+/, $rest);
150 2502         5608 $region->type(\@types);
151 2502         10826 push(@{$self->regions}, $region);
  2502         3799  
152 2502         8409 push(@{$self->things}, $region);
  2502         3804  
153             } elsif (/^(-?\d\d-?\d\d(?:\d\d)?(?:--?\d\d-?\d\d(?:\d\d)?)+)\s+(\S+)\s*(?:["“](.+)["”])?/) {
154 357         986 my $line = $self->make_line(map => $self);
155 357         2249 my $str = $1;
156 357         807 $line->type($2);
157 357         2020 $line->label($3);
158 357         2095 $line->id('line' . $line_id++);
159 357         1505 my @points;
160 357         1187 while ($str =~ /\G(-?\d\d)(-?\d\d)(\d\d)?-?/cg) {
161 2747   50     23547 push(@points, Game::TextMapper::Point->new(x => $1, y => $2, z => $3||'00'));
162             }
163 357         3074 $line->points(\@points);
164 357         1591 push(@{$self->lines}, $line);
  357         618  
165             } elsif (/^(\S+)\s+attributes\s+(.*)/) {
166 136         280 $self->attributes->{$1} = $2;
167             } elsif (/^(\S+)\s+lib\s+(.*)/) {
168 0         0 $self->def(qq{$2});
169             } elsif (/^(\S+)\s+xml\s+(.*)/) {
170 26         86 $self->def(qq{$2});
171             } elsif (/^(<.*>)/) {
172 443         698 $self->def($1);
173             } elsif (/^(\S+)\s+path\s+attributes\s+(.*)/) {
174 36         97 $self->path_attributes->{$1} = $2;
175             } elsif (/^(\S+)\s+path\s+(.*)/) {
176 15         30 $self->path->{$1} = $2;
177             } elsif (/^text\s+(.*)/) {
178 8         33 $self->text_attributes($1);
179             } elsif (/^glow\s+(.*)/) {
180 7         28 $self->glow_attributes($1);
181             } elsif (/^label\s+(.*)/) {
182 8         36 $self->label_attributes($1);
183             } elsif (/^license\s+(.*)/) {
184 5         35 $self->license($1);
185             } elsif (/^other\s+(.*)/) {
186 8         11 push(@{$self->other()}, $1);
  8         18  
187             } elsif (/^url\s+(\S+)/) {
188 1         9 $self->url($1);
189             } elsif (/^include\s+(\S*)/) {
190 8 50       16 if (scalar keys %{$self->seen} > 5) {
  8 50       53  
191 0         0 push(@{$self->messages},
  0         0  
192             "Includes are limited to five to prevent loops");
193             } elsif (not $self->seen->{$1}) {
194 8         96 my $location = $1;
195 8         28 $self->seen->{$location} = 1;
196 8         36 my $path;
197 8 50 33     60 if (index($location, '/') == -1 and -f ($path = Mojo::File->new($self->dist_dir, $location))) {
    0 0        
    0          
    0          
198             # without a slash, it could be a file from dist_dir
199 8         666 $log->debug("Reading $location");
200 8         138 $self->process(split(/\n/, decode_utf8($path->slurp())));
201             } elsif ($self->local_files and -f ($path = Mojo::File->new($location))) {
202             # it could also be a local file in the same directory, but only if
203             # called from the render command (which sets local_files)
204 0         0 $log->debug("Reading $location");
205 0         0 $self->process(split(/\n/, decode_utf8($path->slurp())));
206             } elsif ($location =~ /^https?:/) {
207 0         0 $log->debug("Getting $location");
208 0         0 my $ua = Mojo::UserAgent->new;
209 0         0 my $response = $ua->get($location)->result;
210 0 0       0 if ($response->is_success) {
211 0         0 $self->process(split(/\n/, $response->text));
212             } else {
213 0         0 push(@{$self->messages}, "Getting $location: " . $response->status_line);
  0         0  
214             }
215             } elsif ($self->dist_dir =~ /^https?:/) {
216 0         0 my $url = $self->dist_dir;
217 0 0       0 $url .= '/' unless $url =~ /\/$/;
218 0         0 $url .= $location;
219 0         0 $log->debug("Getting $url");
220 0         0 my $ua = Mojo::UserAgent->new;
221 0         0 my $response = $ua->get($url)->result;
222 0 0       0 if ($response->is_success) {
223 0         0 $self->process(split(/\n/, $response->text));
224             } else {
225 0         0 push(@{$self->messages}, "Getting $url: " . $response->status_line);
  0         0  
226             }
227             } else {
228 0         0 $log->warn("No library '$location' in " . $self->dist_dir);
229 0         0 push(@{$self->messages}, "Library '$location' is must be an existing file on the server or a HTTP/HTTPS URL");
  0         0  
230             }
231             }
232             } else {
233 319 50 66     905 $log->debug("Did not parse $_") if $_ and not /^\s*#/;
234             }
235             }
236 18         222 return $self;
237             }
238              
239             sub def {
240 469     469 0 1329 my ($self, $svg) = @_;
241 469         1965 $svg =~ s/>\s+
242 469         549 push(@{$self->defs}, $svg);
  469         772  
243             }
244              
245             sub merge_attributes {
246 494     494 0 2271 my %attr = ();
247 494         602 for my $attr (@_) {
248 741 100       1365 if ($attr) {
249 70         352 while ($attr =~ /(\S+)=((["']).*?\3)/g) {
250 108         410 $attr{$1} = $2;
251             }
252             }
253             }
254 494         1023 return join(' ', map { $_ . '=' . $attr{$_} } sort keys %attr);
  108         391  
255             }
256              
257             sub svg_header {
258 9     9 0 21 my ($self) = @_;
259              
260 9         19 my $header = qq{
261            
262             xmlns:xlink="http://www.w3.org/1999/xlink"
263             };
264 9 50       11 return $header . "\n" unless @{$self->regions};
  9         21  
265 9         47 my $maxz = 0;
266 9         13 foreach my $region (@{$self->regions}) {
  9         16  
267 2502 50       9253 $maxz = $region->z if $region->z > $maxz;
268             }
269             # These are required to calculate the viewBox for the SVG. Min and max X are
270             # what you would expect. Min and max Y are different, however, since we want
271             # to count all the rows on all the levels, plus an extra separator between
272             # them. Thus, min y is the min y of the first level, and max y is the min y of
273             # the first level + 1 for every level beyond the first, + all the rows for
274             # each level.
275 9         73 my $min_x_overall;
276             my $max_x_overall;
277 9         0 my $min_y_overall;
278 9         0 my $max_y_overall;
279 9         28 for my $z (0 .. $maxz) {
280 9         19 my ($minx, $miny, $maxx, $maxy);
281 9   50     72 $self->offset->[$z] = $max_y_overall // 0;
282 9         24 foreach my $region (@{$self->regions}) {
  9         32  
283 2502 50       10727 next unless $region->z == $z;
284 2502 100 66     10255 $minx = $region->x unless defined $minx and $minx <= $region->x;
285 2502 100 100     11183 $maxx = $region->x unless defined $maxx and $maxx >= $region->x;
286 2502 100 66     11699 $miny = $region->y unless defined $miny and $miny <= $region->y;
287 2502 100 100     11085 $maxy = $region->y unless defined $maxy and $maxy >= $region->y;
288             }
289 9 50 33     62 $min_x_overall = $minx unless defined $min_x_overall and $minx >= $min_x_overall;
290 9 50 33     20 $max_x_overall = $maxx unless defined $min_y_overall and $maxx <= $max_x_overall;;
291 9 50       19 $min_y_overall = $miny unless defined $min_y_overall; # first row of the first level
292 9 50       20 $max_y_overall = $miny unless defined $max_y_overall; # also (!) first row of the first level
293 9 50       24 $max_y_overall += 1 if $z > 0; # plus a separator row for every extra level
294 9         28 $max_y_overall += 1 + $maxy - $miny; # plus the number of rows for every level
295             }
296 9         57 my ($vx1, $vy1, $vx2, $vy2) = $self->viewbox($min_x_overall, $min_y_overall, $max_x_overall, $max_y_overall);
297 9         32 my ($width, $height) = ($vx2 - $vx1, $vy2 - $vy1);
298 9         56 $header .= qq{ viewBox="$vx1 $vy1 $width $height">\n};
299 9         39 $header .= qq{ \n};
300 9         25 return $header;
301             }
302              
303             sub svg_defs {
304 9     9 0 15 my ($self) = @_;
305             # All the definitions are included by default.
306 9         21 my $doc = " \n";
307 9 100       17 $doc .= " " . join("\n ", @{$self->defs}, "") if @{$self->defs};
  7         48  
  9         35  
308             # collect region types from attributess and paths in case the sets don't overlap
309 9         233 my %types = ();
310 9         18 foreach my $region (@{$self->regions}) {
  9         22  
311 2502         2927 foreach my $type (@{$region->type}) {
  2502         3396  
312 4099         8914 $types{$type} = 1;
313             }
314             }
315 9         17 foreach my $line (@{$self->lines}) {
  9         26  
316 357         1155 $types{$line->type} = 1;
317             }
318             # now go through them all
319 9         143 foreach my $type (sort keys %types) {
320 247         420 my $path = $self->path->{$type};
321 247         841 my $attributes = merge_attributes($self->attributes->{$type});
322             my $path_attributes = merge_attributes($self->path_attributes->{'default'},
323 247         504 $self->path_attributes->{$type});
324 247         514 my $glow_attributes = $self->glow_attributes;
325 247 100 100     1226 if ($path || $attributes) {
326 53         125 $doc .= qq{ \n};
327             # just shapes get a glow such, eg. a house (must come first)
328 53 100 100     123 if ($path && !$attributes) {
329 3         12 $doc .= qq{ \n}
330             }
331             # region with attributes get a shape (square or hex), eg. plains and grass
332 53 100       82 if ($attributes) {
333 50         107 $doc .= " " . $self->shape($attributes) . "\n";
334             }
335             # and now the attributes themselves the shape itself
336 53 100       109 if ($path) {
337 8         20 $doc .= qq{ \n}
338             }
339             # close
340 53         102 $doc .= qq{ \n};
341             } else {
342             # nothing
343             }
344             }
345 9         242 $doc .= qq{ \n};
346             }
347              
348             sub svg_backgrounds {
349 9     9 0 19 my $self = shift;
350 9         18 my $doc = qq{ \n};
351 9         17 foreach my $thing (@{$self->things}) {
  9         25  
352             # make a copy
353 2502         11534 my @types = @{$thing->type};
  2502         3711  
354             # keep attributes
355 2502         8624 $thing->type([grep { $self->attributes->{$_} } @{$thing->type}]);
  4099         13566  
  2502         3928  
356 2502         17967 $doc .= $thing->svg($self->offset);
357             # reset copy
358 2502         5191 $thing->type(\@types);
359             }
360 9         59 $doc .= qq{ \n};
361 9         478 return $doc;
362             }
363              
364             sub svg_things {
365 9     9 0 15 my $self = shift;
366 9         27 my $doc = qq{ \n};
367 9         15 foreach my $thing (@{$self->things}) {
  9         28  
368             # drop attributes
369 2502         2865 $thing->type([grep { not $self->attributes->{$_} } @{$thing->type}]);
  4099         14876  
  2502         3732  
370 2502         17942 $doc .= $thing->svg($self->offset);
371             }
372 9         15 $doc .= qq{ \n};
373 9         284 return $doc;
374             }
375              
376             sub svg_coordinates {
377 9     9 0 18 my $self = shift;
378 9         17 my $doc = qq{ \n};
379 9         17 foreach my $region (@{$self->regions}) {
  9         33  
380 2502         4618 $doc .= $region->svg_coordinates($self->offset);
381             }
382 9         22 $doc .= qq{ \n};
383 9         409 return $doc;
384             }
385              
386             sub svg_lines {
387 9     9 0 22 my $self = shift;
388 9         20 my $doc = qq{ \n};
389 9         14 foreach my $line (@{$self->lines}) {
  9         33  
390 357         851 $doc .= $line->svg($self->offset);
391             }
392 9         36 $doc .= qq{ \n};
393 9         200 return $doc;
394             }
395              
396             sub svg_regions {
397 9     9 0 26 my $self = shift;
398 9         18 my $doc = qq{ \n};
399 9   100     29 my $attributes = $self->attributes->{default} || qq{fill="none"};
400 9         70 foreach my $region (@{$self->regions}) {
  9         24  
401 2502         23122 $doc .= $region->svg_region($attributes, $self->offset);
402             }
403 9         709 $doc .= qq{ \n};
404             }
405              
406             sub svg_line_labels {
407 9     9 0 19 my $self = shift;
408 9         19 my $doc = qq{ \n};
409 9         12 foreach my $line (@{$self->lines}) {
  9         32  
410 357         1559 $doc .= $line->svg_label($self->offset);
411             }
412 9         64 $doc .= qq{ \n};
413 9         74 return $doc;
414             }
415              
416             sub svg_labels {
417 9     9 0 18 my $self = shift;
418 9         29 my $doc = qq{ \n};
419 9         17 foreach my $region (@{$self->regions}) {
  9         32  
420 2502         9849 $doc .= $region->svg_label($self->url, $self->offset);
421             }
422 9         43 $doc .= qq{ \n};
423 9         30 return $doc;
424             }
425              
426             =head2 svg()
427              
428             This method generates the SVG once the map is initialized.
429              
430             =cut
431              
432             sub svg {
433 9     9 1 23 my ($self) = @_;
434              
435 9         35 my $doc = $self->svg_header();
436 9         35 $doc .= $self->svg_defs();
437 9         69 $doc .= $self->svg_backgrounds(); # opaque backgrounds
438 9         109 $doc .= $self->svg_lines();
439 9         57 $doc .= $self->svg_things(); # icons, lines
440 9         48 $doc .= $self->svg_coordinates();
441 9         54 $doc .= $self->svg_regions();
442 9         58 $doc .= $self->svg_line_labels();
443 9         53 $doc .= $self->svg_labels();
444 9   100     36 $doc .= $self->license() ||'';
445 9         64 $doc .= join("\n", @{$self->other()}) . "\n";
  9         33  
446              
447             # error messages
448 9         36 my $y = 10;
449 9         22 foreach my $msg (@{$self->messages}) {
  9         58  
450 0         0 $doc .= " $msg\n";
451 0         0 $y += 10;
452             }
453              
454             # source code (comments may not include -- for SGML compatibility!)
455             # https://stackoverflow.com/questions/10842131/xml-comments-and
456 9         29 my $source = $self->map();
457 9         149 $source =~ s/--/--/g;
458 9         147 $doc .= "\n";
459 9         32 $doc .= qq{\n};
460              
461 9         869 return $doc;
462             }
463              
464             =head1 SEE ALSO
465              
466             L is for hex maps.
467              
468             L is for square maps.
469              
470             =cut
471              
472             1;