File Coverage

blib/lib/Games/Sokoban.pm
Criterion Covered Total %
statement 12 170 7.0
branch 0 42 0.0
condition 0 12 0.0
subroutine 4 24 16.6
pod 16 19 84.2
total 32 267 11.9


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Games::Sokoban - load/transform/save sokoban levels in various formats
4              
5             =head1 SYNOPSIS
6              
7             use Games::Sokoban;
8              
9             =head1 DESCRIPTION
10              
11             I needed something like this quickly - if you need better docs, you have to ask.
12              
13             Supports xsb (text), rle, sokevo and a small "binpack" format for input
14             and output and can normalise levels as well as calculate unique IDs.
15              
16             =over 4
17              
18             =cut
19              
20             package Games::Sokoban;
21              
22 1     1   1601 use common::sense;
  1         10  
  1         6  
23              
24 1     1   58 use Carp ();
  1         2  
  1         16  
25 1     1   5 use List::Util ();
  1         6  
  1         989  
26              
27             our $VERSION = '1.01';
28              
29             =item $level = new Games::Sokoban [format => "text|rle|binpack"], [data => "###..."]
30              
31             =cut
32              
33             sub new {
34 0     0 1   my ($class, %arg) = @_;
35              
36 0           my $self = bless \%arg, $class;
37              
38 0 0         $self->data (delete $self->{data}, delete $self->{format})
39             if exists $self->{data};
40              
41 0           $self
42             }
43              
44             =item $level = new_from_file Games::Sokoban $path[, $format]
45              
46             =cut
47              
48             sub new_from_file {
49 0     0 1   my ($class, $path, $format) = @_;
50              
51 0 0         open my $fh, "<:perlio", $path
52             or Carp::croak "$path: $!";
53 0           local $/;
54              
55 0           $class->new (data => (scalar <$fh>), format => $format)
56             }
57              
58             sub detect_format($) {
59 0     0 0   my ($data) = @_;
60              
61 0 0         return "text" if $data =~ /^[ #\@\*\$\.\+\015\012\-_]+$/;
62              
63 0 0         return "rle" if $data =~ /^[ #\@\*\$\.\+\015\012\-_|1-9]+$/;
64              
65 0           my ($a, $b) = unpack "ww", $data;
66 0 0 0       return "binpack" if defined $a && defined $b;
67              
68 0           Carp::croak "unable to autodetect sokoban level format";
69             }
70              
71             =item $level->data ([$new_data, [$new_data_format]])
72              
73             Sets the level from the given data.
74              
75             =cut
76              
77             sub data {
78 0 0   0 1   if (@_ > 1) {
79 0           my ($self, $data, $format) = @_;
80              
81 0   0       $format ||= detect_format $data;
82              
83 0 0 0       if ($format eq "text" or $format eq "rle") {
    0          
84 0           $data =~ y/-_|/ \n/;
85 0           $data =~ s/(\d)(.)/$2 x $1/ge;
  0            
86 0           my @lines = split /[\015\012]+/, $data;
87 0           my $w = List::Util::max map length, @lines;
88              
89             $_ .= " " x ($w - length)
90 0           for @lines;
91              
92 0           $self->{data} = join "\n", @lines;
93              
94             } elsif ($format eq "binpack") {
95 0           (my ($w, $s), $data) = unpack "wwB*", $data;
96              
97 0           my @enc = ('#', '$', '.', ' ', ' ', '###', '*', '# ');
98              
99 0           $data = join "",
100             map $enc[$_],
101             unpack "C*",
102             pack "(b*)*",
103             unpack "(a3)*", $data;
104              
105             # clip extra chars (max. 2)
106 0           my $extra = (length $data) % $w;
107 0 0         substr $data, -$extra, $extra, "" if $extra;
108              
109 0           (substr $data, $s, 1) =~ y/ ./@+/;
110              
111 0           $self->{data} =
112             join "\n",
113             map "#$_#",
114             "#" x $w,
115             (unpack "(a$w)*", $data),
116             "#" x $w;
117            
118             } else {
119 0           Carp::croak "$format: unsupported sokoban level format requested";
120             }
121              
122 0           $self->{format} = $format;
123 0           $self->update;
124             }
125              
126 0           $_[0]{data}
127             }
128              
129             sub pos2xy {
130 1     1   803 use integer;
  1         9  
  1         5  
131              
132 0 0   0 0   $_[1] >= 0
133             or Carp::croak "illegal buffer offset";
134              
135             (
136 0           $_[1] % ($_[0]{w} + 1),
137             $_[1] / ($_[0]{w} + 1),
138             )
139             }
140              
141             sub update {
142 0     0 0   my ($self) = @_;
143              
144 0           for ($self->{data}) {
145 0           s/^\n+//;
146 0           s/\n$//;
147              
148 0 0         /^[^\n]+/ or die;
149              
150 0           $self->{w} = index $_, "\n";
151 0           $self->{h} = y/\n// + 1;
152             }
153             }
154              
155             =item $text = $level->as_text
156              
157             =cut
158              
159             sub as_text {
160 0     0 1   my ($self) = @_;
161              
162 0           "$self->{data}\n"
163             }
164              
165             =item $binary = $level->as_binpack
166              
167             Binpack is a very compact binary format (usually 17% of the size of an xsb
168             file), that is still reasonably easy to encode/decode.
169              
170             It only tries to store simplified levels with full fidelity - other levels
171             can be slightly changed outside the playable area.
172              
173             =cut
174              
175             sub as_binpack {
176 0     0 1   my ($self) = @_;
177              
178 0           my $binpack = chr $self->{w} - 2;
179              
180 0           my $w = $self->{w};
181              
182 0           my $data = $self->{data};
183              
184             # crop away all four borders
185 0           $data =~ s/^#+\n//;
186 0           $data =~ s/#+$//;
187 0           $data =~ s/#$//mg;
188 0           $data =~ s/^#//mg;
189              
190 0           $data =~ y/\n//d;
191              
192 0 0         $data =~ /[\@\+]/ or die;
193 0           my $s = $-[0];
194 0           (substr $data, $s, 1) =~ y/@+/ ./;
195              
196 0           $data =~ s/\#\#\#/101/g;
197 0           $data =~ s/\ \ \ /110/g;
198 0           $data =~ s/\#\ /111/g;
199              
200 0           $data =~ s/\#/000/g;
201 0           $data =~ s/\ /001/g;
202 0           $data =~ s/\./010/g;
203 0           $data =~ s/\*/011/g;
204 0           $data =~ s/\$/100/g;
205              
206             # width, @-offset, data
207              
208 0           pack "wwB*", $w - 2, $s, $data
209             }
210              
211             =item @lines = $level->as_lines
212              
213             =cut
214              
215             sub as_lines {
216 0     0 1   split /\n/, $_[0]{data}
217             }
218              
219             =item $line = $level->as_rle
220              
221             http://www.sokobano.de/wiki/index.php?title=Level_format
222              
223             =cut
224              
225             sub as_rle {
226 0     0 1   my $data = $_[0]{data};
227              
228 0           $data =~ s/ +$//mg;
229 0           $data =~ y/\n /|-/;
230 0           $data =~ s/((.)\2{2,8})/(length $1) . $2/ge;
  0            
231              
232 0           $data
233             }
234              
235             =item ($x, $y) = $level->start
236              
237             Returns (0-based) starting coordinate.
238              
239             =cut
240              
241             sub start {
242 0     0 1   my ($self) = @_;
243              
244 0 0         $self->{data} =~ /[\@\+]/ or Carp::croak "level has no starting point";
245 0           $self->pos2xy ($-[0]);
246             }
247              
248             =item $level->hflip
249              
250             Mirror horizontally.
251              
252             =item $level->vflip
253              
254             Mirror vertically.
255              
256             =item $level->transpose
257              
258             Transpose level (mirror at top-left/bottom-right diagonal).
259              
260             =item $level->rotate_90
261              
262             Rotate by 90 degrees clockwise.
263              
264             =item $level->rotate_180
265              
266             Rotate by 180 degrees clockwise.
267              
268             =cut
269              
270             sub hflip {
271 0     0 1   $_[0]{data} = join "\n", map { scalar reverse $_ } split /\n/, $_[0]{data};
  0            
272             }
273              
274             sub vflip {
275 0     0 1   $_[0]{data} = join "\n", reverse split /\n/, $_[0]{data};
276             }
277              
278             sub transpose {
279 0     0 1   my ($self) = @_;
280              
281             # there must be a more elegant way :/
282 0           my @c;
283              
284 0           for (split /\n/, $self->{data}) {
285 0           my $i;
286              
287 0           $c[$i++] .= $_ for split //;
288             }
289              
290 0           $self->{data} = join "\n", @c;
291 0           ($self->{w}, $self->{h}) = ($self->{h}, $self->{w})
292             }
293              
294             sub rotate_90 {
295 0     0 1   $_[0]->vflip;
296 0           $_[0]->transpose;
297             }
298              
299             sub rotate_180 {
300 0     0 1   $_[0]{data} = reverse $_[0]{data};
301             }
302              
303             =item $id = $level->simplify
304              
305             Detect playable area, crop to smallest size.
306              
307             =cut
308              
309             sub simplify {
310 0     0 1   my ($self) = @_;
311              
312             # first detect playable area
313 0           my ($w, $h) = ($self->{w}, $self->{h});
314 0           my ($x, $y) = $self->start;
315              
316 0           my @data = split /\n/, $self->{data};
317 0           my @mask = @data;
318              
319 0           y/#/\x00/c, y/#/\x7f/ for @mask;
320              
321 0           my @stack = [$x, $y, 0];
322              
323 0           while (@stack) {
324 0           my ($x, $y, $l) = @{ pop @stack };
  0            
325 0           my $line = $mask[$y];
326              
327 0           for my $x ($x .. $x + $l) {
328 0 0         (reverse substr $line, 0, $x + 1) =~ /\x00+/
329             or next;
330              
331 0           $l = $+[0];
332              
333 0           $x -= $l - 1;
334 0 0         (substr $line, $x) =~ /^\x00+/ or die;
335 0           $l = $+[0];
336              
337 0           substr $mask[$y], $x, $l, "\xff" x $l;
338              
339 0 0         push @stack, [$x, $y - 1, $l - 1] if $y > 0;
340 0 0         push @stack, [$x, $y + 1, $l - 1] if $y < $h - 1;
341             }
342             }
343              
344 0           my $walls = "#" x $w;
345              
346 0           for (0 .. $h - 1) {
347 0           $data[$_] = ($data[$_] & $mask[$_]) | ($walls & ~$mask[$_]);
348             }
349              
350             # reduce borders
351 0   0       pop @data while @data > 2 && $data[-2] eq $walls; # bottom
352 0           shift @data while $data[1] eq $walls; # top
353              
354 0           for ($self->{data} = join "\n", @data) {
355 0           s/#$//mg until /[^#]#$/m; # right
356 0           s/^#//mg until /^#[^#]/m; # left
357             }
358              
359             # phew, done
360             }
361              
362             =item $id = $level->normalise
363              
364             Simplifies the level map and calculates/returns its identity code.
365             .
366             http://www.sourcecode.se/sokoban/level_id.php, assume uppercase and hex.
367              
368             =cut
369              
370             sub normalise {
371 0     0 1   my ($self) = @_;
372              
373 0           $self->simplify;
374              
375 0           require Digest::MD5;
376              
377 0           my ($best_md5, $best_data) = "\xff" x 9;
378              
379             my $chk = sub {
380 0     0     my $md5 = substr Digest::MD5::md5 ("$self->{data}\n"), 0, 8;
381 0 0         if ($md5 lt $best_md5) {
382 0           $best_md5 = $md5;
383 0           $best_data = $self->{data};
384             }
385 0           };
386              
387 0           $chk->(); $self->hflip;
  0            
388 0           $chk->(); $self->vflip;
  0            
389 0           $chk->(); $self->hflip;
  0            
390 0           $chk->(); $self->rotate_90;
  0            
391 0           $chk->(); $self->hflip;
  0            
392 0           $chk->(); $self->vflip;
  0            
393 0           $chk->(); $self->hflip;
  0            
394 0           $chk->();
395              
396 0           $self->data ($best_data, "text");
397              
398 0           uc unpack "H*", $best_md5
399             }
400              
401             =item $levels = Games::Sokoban::load_sokevo $path
402              
403             Loads a sokevo snapshot/history file and returns all contained levels as
404             Games::Sokoban objects in an arrayref.
405              
406             =cut
407              
408             sub load_sokevo($) {
409 0 0   0 1   open my $fh, "<:crlf", $_[0]
410             or Carp::croak "$_[0]: $!";
411              
412 0           my @levels;
413              
414             # skip file header
415 0           local $/ = "\n\n";
416 0           scalar <$fh>;
417              
418 0           while (<$fh>) {
419 0           chomp;
420 0           my %meta = split /(?:: |\n)/;
421              
422 0           $_ = <$fh>;
423              
424 0 0         /^##+\n/ or last;
425              
426             # sokevo internally locks some cells
427 0           y/^%:,;-=?/ #.$* +#/;
428              
429             # skip levels without pusher
430 0 0         y/@+// or next;
431              
432 0           push @levels, new Games::Sokoban data => $_, meta => \%meta;
433             }
434              
435             \@levels
436 0           }
437              
438             1;
439              
440             =back
441              
442             =head1 AUTHOR
443              
444             Marc Lehmann
445             http://home.schmorp.de/
446              
447             =cut
448