File Coverage

blib/lib/Game/TextMapper/Mapper.pm
Criterion Covered Total %
statement 262 305 85.9
branch 69 102 67.6
condition 37 71 52.1
subroutine 27 29 93.1
pod 2 18 11.1
total 397 525 75.6


line stmt bran cond sub pod time code
1             # Copyright (C) 2009-2022 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 11     11   21654 use Game::TextMapper::Log;
  11         26  
  11         511  
49 11     11   63 use Modern::Perl '2018';
  11         21  
  11         99  
50 11     11   11923 use Mojo::UserAgent;
  11         3412207  
  11         288  
51 11     11   845 use Mojo::Base -base;
  11         77  
  11         69  
52 11     11   10125 use File::Slurper qw(read_text);
  11         42588  
  11         1054  
53 11     11   125 use Encode qw(encode_utf8 decode_utf8);
  11         25  
  11         907  
54 11     11   95 use Mojo::Util qw(url_escape);
  11         19  
  11         583  
55 11     11   6916 use File::ShareDir 'dist_dir';
  11         432317  
  11         957  
56 11     11   111 use Scalar::Util 'weaken';
  11         25  
  11         120554  
57              
58             =head1 ATTRIBUTES
59              
60             =head2 dist_dir
61              
62             You need to pass this during instantiation so that the mapper knows where to
63             find files it needs to include.
64              
65             =cut
66              
67             has 'local_files';
68             has 'dist_dir';
69             has 'map';
70             has 'regions' => sub { [] };
71             has 'attributes' => sub { {} };
72             has 'defs' => sub { [] };
73             has 'path' => sub { {} };
74             has 'lines' => sub { [] };
75             has 'things' => sub { [] };
76             has 'path_attributes' => sub { {} };
77             has 'text_attributes' => '';
78             has 'glow_attributes' => '';
79             has 'label_attributes' => '';
80             has 'messages' => sub { [] };
81             has 'seen' => sub { {} };
82             has 'license' => '';
83             has 'other' => sub { [] };
84             has 'url' => '';
85             has 'offset' => sub { [] };
86              
87             my $log = Game::TextMapper::Log->get;
88              
89             sub example {
90 1     1 0 5 return <<"EOT";
91             0101 mountain "mountain"
92             0102 swamp "swamp"
93             0103 hill "hill"
94             0104 forest "forest"
95             0201 empty pyramid "pyramid"
96             0202 tundra "tundra"
97             0203 coast "coast"
98             0204 empty house "house"
99             0301 woodland "woodland"
100             0302 wetland "wetland"
101             0303 plain "plain"
102             0304 sea "sea"
103             0401 hill tower "tower"
104             0402 sand house "house"
105             0403 jungle "jungle"
106             0501 mountain cave "cave"
107             0502 sand "sand"
108             0503 hill castle "castle"
109             0205-0103-0202-0303-0402 road
110             0101-0203 river
111             0401-0303-0403 border
112             include default.txt
113             license Public Domain
114             EOT
115             }
116              
117             =head1 METHODS
118              
119             =head2 initialize($map)
120              
121             Call this to load a map into the mapper.
122              
123             =cut
124              
125             sub initialize {
126 11     11 1 176 my ($self, $map) = @_;
127 11         59 $map =~ s/-/-/g; # -- are invalid in source comments...
128 11         98 $self->map($map);
129 11         3287 $self->process(split(/\r?\n/, $map));
130             }
131              
132             sub process {
133 19     19 0 3354 my $self = shift;
134 19         43 my $line_id = 0;
135 19         3230 foreach (@_) {
136 4018 100 66     46396 if (/^(-?\d\d)(-?\d\d)(\d\d)?\s+(.*)/ or /^(-?\d\d+)\.(-?\d\d+)(?:\.(\d\d+))?\s+(.*)/) {
    100 66        
    100          
    50          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
137 2641   50     12370 my $region = $self->make_region(x => $1, y => $2, z => $3||'00', map => $self);
138 2641         28566 weaken($region->{map});
139 2641         5451 my $rest = $4;
140 2641         9522 while (my ($tag, $label, $size) = $rest =~ /\b([a-z]+)=["“]([^"”]+)["”]\s*(\d+)?/) {
141 84 100       202 if ($tag eq 'name') {
142 42         144 $region->label($label);
143 42 50       329 $region->size($size) if $size;
144             } else {
145             # delay the calling of $self->other_info because the URL or the $self->glow_attributes might not be set
146 42     42   69 push(@{$self->other()}, sub () { $self->other_info($region, $label, $size, "translate(0,45)", 'opacity="0.2"') });
  42         142  
  42         133  
147             }
148 84         4255 $rest =~ s/\b([a-z]+)=["“]([^"”]+)["”]\s*(\d+)?//;
149             }
150 2641         7610 while (my ($label, $size, $transform) = $rest =~ /["“]([^"”]+)["”]\s*(\d+)?((?:\s*[a-z]+\([^\)]+\))*)/) {
151 6 50 33     36 if ($transform or $region->label) {
152             # delay the calling of $self->other_text because the URL or the $self->glow_attributes might not be set
153 0     0   0 push(@{$self->other()}, sub () { $self->other_text($region, $label, $size, $transform) });
  0         0  
  0         0  
154             } else {
155 6         46 $region->label($label);
156 6         66 $region->size($size);
157             }
158 6         152 $rest =~ s/["“]([^"”]+)["”]\s*(\d+)?((?:\s*[a-z]+\([^\)]+\))*)//;
159             }
160 2641         6624 my @types = split(/\s+/, $rest);
161 2641         7442 $region->type(\@types);
162 2641         15561 push(@{$self->regions}, $region);
  2641         5460  
163 2641         10692 push(@{$self->things}, $region);
  2641         6323  
164             } elsif (/^(-?\d\d-?\d\d(?:\d\d)?(?:--?\d\d-?\d\d(?:\d\d)?)+)\s+(\S+)\s*(?:["“](.+)["”])?\s*(left|right)?\s*(\d+%)?/
165             or /^(-?\d\d+\.-?\d\d+(?:\.\d\d+)?(?:--?\d\d+\.-?\d\d+(?:\.\d\d+)?)+)\s+(\S+)\s*(?:["“](.+)["”])?\s*(left|right)?\s*(\d+%)?/) {
166 352         1423 my $line = $self->make_line(map => $self);
167 352         2920 weaken($line->{map});
168 352         984 my $str = $1;
169 352         1121 $line->type($2);
170 352         2949 $line->label($3);
171 352         2691 $line->side($4);
172 352         2535 $line->start($5);
173 352         2937 $line->id('line' . $line_id++);
174 352         2466 my @points;
175 352         1919 while ($str =~ /\G(?:(-?\d\d)(-?\d\d)(\d\d)?|(-?\d\d+)\.(-?\d\d+)\.(\d\d+)?)-?/cg) {
176 2382   33     36174 push(@points, Game::TextMapper::Point->new(x => $1||$4, y => $2||$5, z => $3||$6||'00'));
      33        
      50        
177             }
178 352         3904 $line->points(\@points);
179 352         2230 push(@{$self->lines}, $line);
  352         1057  
180             } elsif (/^(\S+)\s+attributes\s+(.*)/) {
181 141         367 $self->attributes->{$1} = $2;
182             } elsif (/^(\S+)\s+lib\s+(.*)/) {
183 0         0 $self->def(qq{$2});
184             } elsif (/^(\S+)\s+xml\s+(.*)/) {
185 26         135 $self->def(qq{$2});
186             } elsif (/^(<.*>)/) {
187 443         1016 $self->def($1);
188             } elsif (/^(\S+)\s+path\s+attributes\s+(.*)/) {
189 36         115 $self->path_attributes->{$1} = $2;
190             } elsif (/^(\S+)\s+path\s+(.*)/) {
191 15         40 $self->path->{$1} = $2;
192             } elsif (/^text\s+(.*)/) {
193 8         66 $self->text_attributes($1);
194             } elsif (/^glow\s+(.*)/) {
195 7         39 $self->glow_attributes($1);
196             } elsif (/^label\s+(.*)/) {
197 8         72 $self->label_attributes($1);
198             } elsif (/^license\s+(.*)/) {
199 5         69 $self->license($1);
200             } elsif (/^other\s+(.*)/) {
201 8         15 push(@{$self->other()}, $1);
  8         24  
202             } elsif (/^url\s+(\S+)/) {
203 1         13 $self->url($1);
204             } elsif (/^include\s+(\S*)/) {
205 8 50       21 if (scalar keys %{$self->seen} > 5) {
  8 50       70  
206 0         0 push(@{$self->messages},
  0         0  
207             "Includes are limited to five to prevent loops");
208             } elsif (not $self->seen->{$1}) {
209 8         102 my $location = $1;
210 8         31 $self->seen->{$location} = 1;
211 8         50 my $path;
212 8 50 33     70 if (index($location, '/') == -1 and -f ($path = Mojo::File->new($self->dist_dir, $location))) {
    0 0        
    0          
    0          
213             # without a slash, it could be a file from dist_dir
214 8         1640 $log->debug("Reading $location");
215 8         214 $self->process(split(/\n/, decode_utf8($path->slurp())));
216             } elsif ($self->local_files and -f ($path = Mojo::File->new($location))) {
217             # it could also be a local file in the same directory, but only if
218             # called from the render command (which sets local_files)
219 0         0 $log->debug("Reading $location");
220 0         0 $self->process(split(/\n/, decode_utf8($path->slurp())));
221             } elsif ($location =~ /^https?:/) {
222 0         0 $log->debug("Getting $location");
223 0         0 my $ua = Mojo::UserAgent->new;
224 0         0 my $response = $ua->get($location)->result;
225 0 0       0 if ($response->is_success) {
226 0         0 $self->process(split(/\n/, $response->text));
227             } else {
228 0         0 push(@{$self->messages}, "Getting $location: " . $response->status_line);
  0         0  
229             }
230             } elsif ($self->dist_dir =~ /^https?:/) {
231 0         0 my $url = $self->dist_dir;
232 0 0       0 $url .= '/' unless $url =~ /\/$/;
233 0         0 $url .= $location;
234 0         0 $log->debug("Getting $url");
235 0         0 my $ua = Mojo::UserAgent->new;
236 0         0 my $response = $ua->get($url)->result;
237 0 0       0 if ($response->is_success) {
238 0         0 $self->process(split(/\n/, $response->text));
239             } else {
240 0         0 push(@{$self->messages}, "Getting $url: " . $response->status_line);
  0         0  
241             }
242             } else {
243 0         0 $log->warn("No library '$location' in " . $self->dist_dir);
244 0         0 push(@{$self->messages}, "Library '$location' is must be an existing file on the server or a HTTP/HTTPS URL");
  0         0  
245             }
246             }
247             } else {
248 319 50 66     1270 $log->debug("Did not parse $_") if $_ and not /^\s*#/;
249             }
250             }
251 19         399 return $self;
252             }
253              
254             sub svg_other {
255 10     10 0 32 my ($self) = @_;
256 10         26 my $data = "\n";
257 10         19 for my $other (@{$self->other()}) {
  10         71  
258 50 100       168 if (ref $other eq 'CODE') {
259 42         112 $data .= $other->();
260             } else {
261 8         20 $data .= $other;
262             }
263 50         150 $data .= "\n";
264             }
265 10         43 $self->other(undef);
266 10         181 return $data;
267             }
268              
269             # Very similar to svg_label, but given that we have a transformation, we
270             # translate the object to it's final position.
271             sub other_text {
272 0     0 0 0 my ($self, $region, $label, $size, $transform) = @_;
273 0         0 $transform = sprintf("translate(%.1f,%.1f)", $region->pixels($self->offset)) . $transform;
274 0         0 my $attributes = "transform=\"$transform\" " . $self->label_attributes;
275 0 0 0     0 if ($size and not $attributes =~ s/\bfont-size="\d+pt"/font-size="$size"/) {
276 0         0 $attributes .= " font-size=\"$size\"";
277             }
278 0   0     0 my $data = sprintf(qq{ %s},
279             $attributes, $self->glow_attributes||'', $label);
280 0         0 my $url = $self->url;
281 0 0 0     0 $url =~ s/\%s/url_escape(encode_utf8($label))/e or $url .= url_escape(encode_utf8($label)) if $url;
  0         0  
282 0         0 $data .= sprintf(qq{%s},
283             $url, $attributes, $label);
284 0         0 $data .= qq{\n};
285 0         0 return $data;
286             }
287              
288             # Very similar to other_text, but without a link and we have extra attributes
289             sub other_info {
290 42     42 0 111 my ($self, $region, $label, $size, $transform, $attributes) = @_;
291 42         150 $transform = sprintf("translate(%.1f,%.1f)", $region->pixels($self->offset)) . $transform;
292 42         92 $attributes .= " transform=\"$transform\"";
293 42 50       117 $attributes .= " " . $self->label_attributes if $self->label_attributes;
294 42 50 33     417 if ($size and not $attributes =~ s/\bfont-size="\d+pt"/font-size="$size"/) {
295 0         0 $attributes .= " font-size=\"$size\"";
296             }
297 42   50     110 my $data = sprintf(qq{ %s}, $attributes, $self->glow_attributes||'', $label);
298 42         420 $data .= sprintf(qq{%s\n}, $attributes, $label);
299 42         199 return $data;
300             }
301              
302             sub def {
303 469     469 0 1640 my ($self, $svg) = @_;
304 469         1968 $svg =~ s/>\s+
305 469         692 push(@{$self->defs}, $svg);
  469         963  
306             }
307              
308             sub merge_attributes {
309 450     450 0 2961 my %attr = ();
310 450         837 for my $attr (@_) {
311 675 100       1827 if ($attr) {
312 61         723 while ($attr =~ /(\S+)=((["']).*?\3)/g) {
313 97         714 $attr{$1} = $2;
314             }
315             }
316             }
317 450         1250 return join(' ', map { $_ . '=' . $attr{$_} } sort keys %attr);
  97         1067  
318             }
319              
320             sub svg_header {
321 10     10 0 33 my ($self) = @_;
322              
323 10         48 my $header = qq{
324            
325             xmlns:xlink="http://www.w3.org/1999/xlink"
326             };
327 10 50       19 return $header . "\n" unless @{$self->regions};
  10         44  
328 10         89 my $maxz = 0;
329 10         22 foreach my $region (@{$self->regions}) {
  10         29  
330 2641 50       13233 $maxz = $region->z if $region->z > $maxz;
331             }
332             # These are required to calculate the viewBox for the SVG. Min and max X are
333             # what you would expect. Min and max Y are different, however, since we want
334             # to count all the rows on all the levels, plus an extra separator between
335             # them. Thus, min y is the min y of the first level, and max y is the min y of
336             # the first level + 1 for every level beyond the first, + all the rows for
337             # each level.
338 10         163 my $min_x_overall;
339             my $max_x_overall;
340 10         0 my $min_y_overall;
341 10         0 my $max_y_overall;
342 10         35 for my $z (0 .. $maxz) {
343 10         25 my ($minx, $miny, $maxx, $maxy);
344 10   50     95 $self->offset->[$z] = $max_y_overall // 0;
345 10         21 foreach my $region (@{$self->regions}) {
  10         41  
346 2641 50       14617 next unless $region->z == $z;
347 2641 100 66     14733 $minx = $region->x unless defined $minx and $minx <= $region->x;
348 2641 100 100     15616 $maxx = $region->x unless defined $maxx and $maxx >= $region->x;
349 2641 100 66     15182 $miny = $region->y unless defined $miny and $miny <= $region->y;
350 2641 100 100     14879 $maxy = $region->y unless defined $maxy and $maxy >= $region->y;
351             }
352 10 50 33     75 $min_x_overall = $minx unless defined $min_x_overall and $minx >= $min_x_overall;
353 10 50 33     43 $max_x_overall = $maxx unless defined $min_y_overall and $maxx <= $max_x_overall;;
354 10 50       176 $min_y_overall = $miny unless defined $min_y_overall; # first row of the first level
355 10 50       49 $max_y_overall = $miny unless defined $max_y_overall; # also (!) first row of the first level
356 10 50       30 $max_y_overall += 1 if $z > 0; # plus a separator row for every extra level
357 10         44 $max_y_overall += 1 + $maxy - $miny; # plus the number of rows for every level
358             }
359 10         87 my ($vx1, $vy1, $vx2, $vy2) = $self->viewbox($min_x_overall, $min_y_overall, $max_x_overall, $max_y_overall);
360 10         38 my ($width, $height) = ($vx2 - $vx1, $vy2 - $vy1);
361 10         63 $header .= qq{ viewBox="$vx1 $vy1 $width $height">\n};
362 10         39 $header .= qq{ \n};
363 10         43 return $header;
364             }
365              
366             sub svg_defs {
367 10     10 0 25 my ($self) = @_;
368             # All the definitions are included by default.
369 10         23 my $doc = " \n";
370 10 100       18 $doc .= " " . join("\n ", @{$self->defs}, "") if @{$self->defs};
  7         74  
  10         48  
371             # collect region types from attributess and paths in case the sets don't overlap
372 10         330 my %types = ();
373 10         17 foreach my $region (@{$self->regions}) {
  10         72  
374 2641         4191 foreach my $type (@{$region->type}) {
  2641         4709  
375 4115         12404 $types{$type} = 1;
376             }
377             }
378 10         21 foreach my $line (@{$self->lines}) {
  10         66  
379 352         1667 $types{$line->type} = 1;
380             }
381             # now go through them all
382 10         242 foreach my $type (sort keys %types) {
383 225         861 my $path = $self->path->{$type};
384 225         1088 my $attributes = merge_attributes($self->attributes->{$type});
385             my $path_attributes = merge_attributes($self->path_attributes->{'default'},
386 225         613 $self->path_attributes->{$type});
387 225         744 my $glow_attributes = $self->glow_attributes;
388 225 100 100     1665 if ($path || $attributes) {
389 45         140 $doc .= qq{ \n};
390             # just shapes get a glow such, eg. a house (must come first)
391 45 100 100     130 if ($path && !$attributes) {
392 3         9 $doc .= qq{ \n}
393             }
394             # region with attributes get a shape (square or hex), eg. plains and grass
395 45 100       82 if ($attributes) {
396 42         149 $doc .= " " . $self->shape($attributes) . "\n";
397             }
398             # and now the attributes themselves the shape itself
399 45 100       103 if ($path) {
400 8         17 $doc .= qq{ \n}
401             }
402             # close
403 45         98 $doc .= qq{ \n};
404             } else {
405             # nothing
406             }
407             }
408 10         489 $doc .= qq{ \n};
409             }
410              
411             sub svg_backgrounds {
412 10     10 0 23 my $self = shift;
413 10         38 my $doc = qq{ \n};
414 10         19 foreach my $thing (@{$self->things}) {
  10         104  
415             # make a copy
416 2641         22978 my @types = @{$thing->type};
  2641         6750  
417             # keep attributes
418 2641         12856 $thing->type([grep { $self->attributes->{$_} } @{$thing->type}]);
  4115         20866  
  2641         5121  
419 2641         28444 $doc .= $thing->svg($self->offset);
420             # reset copy
421 2641         6962 $thing->type(\@types);
422             }
423 10         106 $doc .= qq{ \n};
424 10         439 return $doc;
425             }
426              
427             sub svg_things {
428 10     10 0 30 my $self = shift;
429 10         33 my $doc = qq{ \n};
430 10         46 foreach my $thing (@{$self->things}) {
  10         55  
431             # drop attributes
432 2641         4540 $thing->type([grep { not $self->attributes->{$_} } @{$thing->type}]);
  4115         22095  
  2641         6252  
433 2641         31362 $doc .= $thing->svg($self->offset);
434             }
435 10         49 $doc .= qq{ \n};
436 10         489 return $doc;
437             }
438              
439             sub svg_coordinates {
440 10     10 0 30 my $self = shift;
441 10         26 my $doc = qq{ \n};
442 10         21 foreach my $region (@{$self->regions}) {
  10         89  
443 2641         7260 $doc .= $region->svg_coordinates($self->offset);
444             }
445 10         31 $doc .= qq{ \n};
446 10         516 return $doc;
447             }
448              
449             sub svg_lines {
450 10     10 0 65 my $self = shift;
451 10         28 my $doc = qq{ \n};
452 10         20 foreach my $line (@{$self->lines}) {
  10         49  
453 352         1542 $doc .= $line->svg($self->offset);
454             }
455 10         55 $doc .= qq{ \n};
456 10         345 return $doc;
457             }
458              
459             sub svg_regions {
460 10     10 0 29 my $self = shift;
461 10         25 my $doc = qq{ \n};
462 10   100     148 my $attributes = $self->attributes->{default} || qq{fill="none"};
463 10         126 foreach my $region (@{$self->regions}) {
  10         52  
464 2641         9405 $doc .= $region->svg_region($attributes, $self->offset);
465             }
466 10         1371 $doc .= qq{ \n};
467             }
468              
469             sub svg_line_labels {
470 10     10 0 30 my $self = shift;
471 10         27 my $doc = qq{ \n};
472 10         32 foreach my $line (@{$self->lines}) {
  10         59  
473 352         2239 $doc .= $line->svg_label($self->offset);
474             }
475 10         109 $doc .= qq{ \n};
476 10         64 return $doc;
477             }
478              
479             sub svg_labels {
480 10     10 0 32 my $self = shift;
481 10         27 my $doc = qq{ \n};
482 10         36 foreach my $region (@{$self->regions}) {
  10         51  
483 2641         14882 $doc .= $region->svg_label($self->url, $self->offset);
484             }
485 10         62 $doc .= qq{ \n};
486 10         69 return $doc;
487             }
488              
489             =head2 svg()
490              
491             This method generates the SVG once the map is initialized.
492              
493             =cut
494              
495             sub svg {
496 10     10 1 36 my ($self) = @_;
497              
498 10         61 my $doc = $self->svg_header();
499 10         65 $doc .= $self->svg_defs();
500 10         93 $doc .= $self->svg_backgrounds(); # opaque backgrounds
501 10         78 $doc .= $self->svg_lines();
502 10         70 $doc .= $self->svg_things(); # icons, lines
503 10         86 $doc .= $self->svg_coordinates();
504 10         88 $doc .= $self->svg_regions();
505 10         95 $doc .= $self->svg_line_labels();
506 10         123 $doc .= $self->svg_labels();
507 10   100     63 $doc .= $self->license() ||'';
508 10         183 $doc .= $self->svg_other();
509              
510             # error messages
511 10         26 my $y = 10;
512 10         49 foreach my $msg (@{$self->messages}) {
  10         49  
513 0         0 $doc .= " $msg\n";
514 0         0 $y += 10;
515             }
516              
517             # source code (comments may not include -- for SGML compatibility!)
518             # https://stackoverflow.com/questions/10842131/xml-comments-and
519 10         60 my $source = $self->map();
520 10         151 $source =~ s/--/--/g;
521 10         114 $doc .= "\n";
522 10         26 $doc .= qq{\n};
523              
524 10         1528 return $doc;
525             }
526              
527             =head1 SEE ALSO
528              
529             L is for hex maps.
530              
531             L is for square maps.
532              
533             =cut
534              
535             1;