File Coverage

blib/lib/Game/PlatformsOfPeril.pm
Criterion Covered Total %
statement 31 399 7.7
branch 0 134 0.0
condition 0 94 0.0
subroutine 11 55 20.0
pod 0 41 0.0
total 42 723 5.8


line stmt bran cond sub pod time code
1             # -*- Perl -*-
2             #
3             # Game::PlatformsOfPeril - this is a terminal-based game, run the
4             # `pperil` command that should be installed with this module to begin
5             #
6             # some details for the unwary, or brave, regarding the code:
7             #
8             # this implementation uses arrays heavily so instead of a more typical
9             # Player object there is an array with various slots that are used for
10             # various purposes. these slots are indexed using constant subs, and
11             # there is some overlap of these slots for animates, items, and terrain.
12             # the @Animates array (where the player, monsters, and items reside) and
13             # $LMap (level map, which has every ROW and COL and then an array (LMC)
14             # for what is in that cell) is where most of the game data resides.
15             # there can be only one terrain (GROUND), one ITEM, and one animate
16             # (ANI) per level map cell; any new interactions will need to support
17             # this. there are also four graphs per level map; these graphs dictate
18             # what moves are possible for animates (double benefit of providing both
19             # legal next moves and for pathfinding across the map). gravity pulls
20             # things down at the beginning of a turn (bottom up), and the player
21             # always moves first in the turn (low id to high), see the game_loop.
22             # level maps are ASCII text, and only one thing can be present in a cell
23             # in the map (with FLOOR being assumed present below any item or
24             # animate). there are some complications around killing things off; dead
25             # things must not interact with anything, but may still be looped to
26             # after their death in the apply_gravity or game_loop UPDATE calls.
27             # hence the BLACK_SPOT
28              
29             package Game::PlatformsOfPeril;
30              
31             our $VERSION = '0.08';
32              
33 1     1   60049 use 5.24.0;
  1         3  
34 1     1   13 use warnings;
  1         3  
  1         28  
35 1     1   426 use File::Spec::Functions qw(catfile);
  1         687  
  1         47  
36 1     1   411 use List::PriorityQueue ();
  1         711  
  1         19  
37 1     1   6 use List::Util qw(first);
  1         2  
  1         88  
38 1     1   403 use List::UtilsBy 0.06 qw(nsort_by rev_nsort_by);
  1         1668  
  1         59  
39 1     1   6 use Scalar::Util qw(weaken);
  1         2  
  1         40  
40 1     1   423 use Term::ReadKey qw(GetTerminalSize ReadKey ReadMode);
  1         1623  
  1         57  
41 1     1   451 use Time::HiRes qw(gettimeofday sleep tv_interval);
  1         1051  
  1         4  
42 1     1   648 use POSIX qw(STDIN_FILENO TCIFLUSH tcflush);
  1         5188  
  1         5  
43              
44             # ANSI or XTerm control sequences
45 0     0 0 0 sub at { "\e[" . $_[1] . ';' . $_[0] . 'H' }
46             sub alt_screen () { "\e[?1049h" }
47             sub clear_screen () { "\e[1;1H\e[2J" }
48             sub clear_right () { "\e[K" }
49             sub hide_cursor () { "\e[?25l" }
50             sub hide_pointer () { "\e[>3p" }
51             sub show_cursor () { "\e[?25h" }
52             sub term_norm () { "\e[m" }
53             sub unalt_screen () { "\e[?1049l" }
54              
55             # WHAT Animates and such can be
56             sub HERO () { 0 }
57             sub MONST () { 1 }
58             sub BOMB () { 2 }
59             sub GEM () { 3 }
60             sub FLOOR () { 4 }
61             sub WALL () { 5 }
62             sub LADDER () { 6 }
63             sub STAIR () { 7 }
64             sub STATUE () { 8 }
65              
66             sub BOMB_COST () { 2 }
67             sub GEM_VALUE () { 1 }
68              
69             # for the Level Map Cell (LMC)
70             sub WHERE () { 0 }
71             sub GROUND () { 1 }
72             sub ITEM () { 2 }
73             sub ANI () { 3 }
74              
75             sub MOVE_FAILED () { 0 }
76             sub MOVE_OK () { 1 }
77             sub MOVE_NEWLVL () { 2 }
78              
79             # for the level map
80             sub COLS () { 23 }
81             sub ROWS () { 23 }
82             sub MAP_DISP_OFF () { 1 }
83              
84             # level map is row, col while points are [ col, row ]
85             sub PROW () { 1 }
86             sub PCOL () { 0 }
87              
88             sub MSG_ROW () { 1 }
89             sub MSG_COL () { 25 }
90             # these also used to determine the minimum size for the terminal
91             sub MSG_MAX () { 24 }
92             sub MSG_COLS_MAX () { 70 }
93              
94             # for Animates (and also some Things for the first few slots)
95             sub WHAT () { 0 }
96             sub DISP () { 1 }
97             # NOTE that GROUND use TYPE to distinguish between different types of
98             # those (FLOOR, STAIR, STATUE) which makes the graph code simpler as
99             # that only needs to look at WHAT for whether motion is possible in that
100             # cell; ANI and ITEM instead use TYPE to tell ANI apart from ITEM
101             sub TYPE () { 2 }
102             sub STASH () { 3 }
103             sub UPDATE () { 4 }
104             sub LMC () { 5 }
105             sub BLACK_SPOT () { 6 }
106              
107             sub GEM_STASH () { 0 }
108             sub BOMB_STASH () { 1 }
109             sub GEM_ODDS () { 1 }
110              
111             sub GEM_ODDS_ADJUST () { 0.05 }
112              
113             sub START_GEMS () { 0 }
114             sub START_BOMBS () { 1 }
115              
116             sub GRAPH_NODE () { 0 }
117             sub GRAPH_WEIGHT () { 1 }
118             sub GRAPH_POINT () { 2 }
119              
120             our %CharMap = (
121             'o' => BOMB,
122             '.' => FLOOR,
123             '*' => GEM,
124             '@' => HERO,
125             '=' => LADDER,
126             'P' => MONST,
127             '%' => STAIR,
128             '&' => STATUE,
129             '#' => WALL,
130             );
131              
132             our (
133             @Animates, @Graphs, $LMap, $Monst_Name, @RedrawA,
134             @RedrawB, $Hero, $TCols, $TRows
135             );
136              
137             our %Examine_Offsets = (
138             'h' => [ -1, +0 ], # left
139             'j' => [ +0, +1 ], # down
140             'k' => [ +0, -1 ], # up
141             'l' => [ +1, +0 ], # right
142             'y' => [ -1, -1 ],
143             'u' => [ +1, -1 ],
144             'b' => [ -1, +1 ],
145             'n' => [ +1, +1 ],
146             );
147              
148             our $Level = 0;
149             our $Level_Path;
150              
151             # plosive practice. these must pluralize properly
152             our @Menagerie = (
153             'Palace Peacock',
154             'Peckish Packrat',
155             'Peevish Penguin',
156             'Piratical Parakeet',
157             'Placid Piranha',
158             'Pleasant Porcupine',
159             'Priggish Python',
160             'Prurient Pachyderm',
161             'Purposeful Plant',
162             # and some not-plosives for reasons lost in the mists of time
163             'Gruesome Goose',
164             'Sinister Swan',
165             );
166             $Monst_Name = $Menagerie[ rand @Menagerie ];
167              
168             our $Redraw_Delay = 0.05;
169             our $Rotate_Delay = 0.20;
170             our $Rotation = 0;
171              
172             our @Scientists = qw(Eigen Maxwell Newton);
173             our $Scientist = $Scientists[ rand @Scientists ];
174              
175             our $Seed;
176              
177             our @Styles =
178             qw(Abstract Art-Deco Brutalist Egyptian Greek Impressionist Post-Modern Roman Romantic);
179             our $Style = $Styles[ rand @Styles ];
180              
181             our %Things = (
182             BOMB, [ BOMB, "\e[31mo\e[0m", ITEM ],
183             FLOOR, [ FLOOR, "\e[33m.\e[0m", FLOOR ],
184             GEM, [ GEM, "\e[32m*\e[0m", ITEM ],
185             LADDER, [ LADDER, "\e[37m=\e[0m", LADDER ],
186             STAIR, [ FLOOR, "\e[37m%\e[0m", STAIR ],
187             STATUE, [ FLOOR, "\e[1;33m&\e[0m", STATUE ],
188             WALL, [ WALL, "\e[35m#\e[0m", WALL ],
189             );
190              
191             our %Descriptions = (
192             BOMB, 'Bomb. Avoid.',
193             FLOOR, 'Empty cell.',
194             GEM, 'A gem. Get these.',
195             HERO, 'The much suffering hero.',
196             LADDER, 'A ladder.',
197             MONST, $Monst_Name . '. Wants to kill you.',
198             STAIR, 'A way out of this mess.',
199             STATUE, 'Empty cell with decorative statue.',
200             WALL, 'A wall.',
201             );
202              
203             $Animates[HERO]->@[ WHAT, DISP, TYPE, STASH, UPDATE ] =
204             (HERO, "\e[1;33m\@\e[0m", ANI, [ START_GEMS, START_BOMBS ], \&update_hero);
205              
206             our %Interact_With = (
207             HERO, # the target of the mover
208             sub {
209             my ($mover, $target) = @_;
210             game_over_monster() if $mover->[WHAT] == MONST;
211             game_over_bomb() if $mover->[WHAT] == BOMB;
212             grab_gem($target, $mover);
213             },
214             MONST,
215             sub {
216             my ($mover, $target) = @_;
217             game_over_monster() if $mover->[WHAT] == HERO;
218             if ($mover->[WHAT] == BOMB) {
219             my @cells = map { kill_animate($_, 1); $_->[LMC][WHERE] } $mover, $target;
220             redraw_ref(\@cells);
221             explode($target);
222             } elsif ($mover->[WHAT] == GEM) {
223             grab_gem($target, $mover);
224             }
225             },
226             BOMB,
227             sub {
228             my ($mover, $target) = @_;
229             game_over_bomb() if $mover->[WHAT] == HERO;
230             if ($mover->[WHAT] == MONST) {
231             my @cells = map { kill_animate($_, 1); $_->[LMC][WHERE] } $mover, $target;
232             redraw_ref(\@cells);
233             explode($mover);
234             }
235             },
236             GEM,
237             sub {
238             my ($mover, $target) = @_;
239             if ($mover->[TYPE] == ANI) {
240             relocate($mover, $target->[LMC][WHERE]);
241             grab_gem($mover, $target);
242             }
243             },
244             );
245              
246             our %Key_Commands = (
247             'h' => move_player(-1, +0), # left
248             'j' => move_player(+0, +1), # down
249             'k' => move_player(+0, -1), # up
250             'l' => move_player(+1, +0), # right
251             '.' => \&move_nop, # rest
252             ' ' => \&move_nop, # also rest
253             'v' => sub { post_message('Version ' . $VERSION); return MOVE_FAILED },
254             'x' => \&move_examine,
255             '<' => sub {
256             post_message($Scientist . q{'s magic wonder left boot, activate!});
257             rotate_left();
258             print draw_level();
259             sleep($Rotate_Delay);
260             return MOVE_OK;
261             },
262             '>' => sub {
263             post_message($Scientist . q{'s magic wonder right boot, activate!});
264             rotate_right();
265             print draw_level();
266             sleep($Rotate_Delay);
267             return MOVE_OK;
268             },
269             '?' => sub {
270             post_help();
271             return MOVE_FAILED;
272             },
273             # for debugging, probably shouldn't be included as it shows exactly
274             # where the monsters are trying to move to which may or may not be
275             # where the player is
276             'T' => sub {
277             local $" = ',';
278             post_message("T $Hero->@* R $Rotation");
279             return MOVE_FAILED;
280             },
281             '@' => sub {
282             local $" = ',';
283             post_message("\@ $Animates[HERO][LMC][WHERE]->@* R $Rotation");
284             return MOVE_FAILED;
285             },
286             '$' => sub {
287             post_message('You have '
288             . $Animates[HERO][STASH][BOMB_STASH]
289             . ' bombs and '
290             . $Animates[HERO][STASH][GEM_STASH]
291             . ' gems.');
292             return MOVE_FAILED;
293             },
294             # by way of history '%' is what rogue (version 3.6) uses for stairs,
295             # except the '>' (or very rarely '<') keys are used to interact with
296             # that symbol
297             '%' => sub {
298             if ($Animates[HERO][LMC][GROUND][TYPE] == STAIR) {
299             load_level();
300             print clear_screen(), draw_level();
301             post_message('Level '
302             . $Level
303             . ' (You have '
304             . $Animates[HERO][STASH][BOMB_STASH]
305             . ' bombs and '
306             . $Animates[HERO][STASH][GEM_STASH]
307             . ' gems.)');
308             return MOVE_NEWLVL;
309             } else {
310             post_message('There are no stairs here?');
311             return MOVE_FAILED;
312             }
313             },
314             'B' => sub {
315             my $lmc = $Animates[HERO][LMC];
316             return MOVE_FAILED, 'You have no bombs (make them from gems).'
317             if $Animates[HERO][STASH][BOMB_STASH] < 1;
318             return MOVE_FAILED, 'There is already an item in this cell.'
319             if defined $lmc->[ITEM];
320             $Animates[HERO][STASH][BOMB_STASH]--;
321             make_item($lmc->[WHERE], BOMB, 0);
322             return MOVE_OK;
323             },
324             'M' => sub {
325             return MOVE_FAILED, 'You need more gems.'
326             if $Animates[HERO][STASH][GEM_STASH] < BOMB_COST;
327             $Animates[HERO][STASH][GEM_STASH] -= BOMB_COST;
328             post_message('You now have ' . ++$Animates[HERO][STASH][BOMB_STASH] . ' bombs');
329             return MOVE_OK;
330             },
331             'q' => sub { game_over('Be seeing you...') },
332             "\003" => sub { #
333             post_message('Enough with these silly interruptions!');
334             return MOVE_FAILED;
335             },
336             "\014" => sub { #
337             redraw_level();
338             return MOVE_FAILED;
339             },
340             "\032" => sub { #
341             post_message('You hear a strange noise in the background.');
342             return MOVE_FAILED;
343             },
344             "\033" => sub {
345             post_message('You cannot escape quite so easily.');
346             return MOVE_FAILED;
347             },
348             );
349              
350             sub apply_gravity {
351 0     0 0 0 for my $ent (rev_nsort_by { $_->[LMC][WHERE][PROW] } @Animates) {
  0     0   0  
352 0 0       0 next if $ent->[BLACK_SPOT];
353 0         0 my $here = $ent->[LMC][WHERE];
354             next
355 0 0 0     0 if $here->[PROW] == ROWS - 1
      0        
      0        
356             or ( $ent->[TYPE] == ANI
357             and $LMap->[ $here->[PROW] ][ $here->[PCOL] ][GROUND][WHAT] == LADDER)
358             or $LMap->[ $here->[PROW] + 1 ][ $here->[PCOL] ][GROUND][WHAT] == WALL;
359 0         0 my $dest = [ $here->[PCOL], $here->[PROW] + 1 ];
360 0 0       0 relocate($ent, $dest) unless interact($ent, $dest);
361 0 0       0 if ($ent->[WHAT] == HERO) {
362 0 0       0 if ($ent->[LMC][GROUND][WHAT] == LADDER) {
363 0         0 post_message('You fall, but grab onto a ladder.');
364             } else {
365 0         0 post_message('You fall!');
366             }
367             }
368             }
369             }
370              
371             sub bad_terminal {
372 0     0 0 0 ($TCols, $TRows) = (GetTerminalSize * STDOUT)[ 0, 1 ];
373 0   0     0 return (not defined $TCols or $TCols < MSG_COLS_MAX or $TRows < MSG_MAX);
374             }
375              
376             sub bail_out {
377 0     0 0 0 restore_term();
378 0 0       0 warn $_[0] if @_;
379 0         0 game_over("Suddenly, the platforms collapse about you.");
380             }
381              
382             sub between {
383 0     0 0 0 my ($min, $max, $value) = @_;
384 0 0       0 if ($value < $min) {
    0          
385 0         0 $value = $min;
386             } elsif ($value > $max) {
387 0         0 $value = $max;
388             }
389 0         0 return $value;
390             }
391              
392             sub draw_level {
393 0     0 0 0 my $s = '';
394 0         0 for my $rownum (0 .. ROWS - 1) {
395 0         0 $s .= at(MAP_DISP_OFF, MAP_DISP_OFF + $rownum);
396 0         0 for my $lmc ($LMap->[$rownum]->@*) {
397 0 0       0 if (defined $lmc->[ANI]) {
    0          
398 0         0 $s .= $lmc->[ANI][DISP];
399             } elsif (defined $lmc->[ITEM]) {
400 0         0 $s .= $lmc->[ITEM][DISP];
401             } else {
402 0         0 $s .= $lmc->[GROUND][DISP];
403             }
404             }
405             }
406 0         0 $s .= at(1, ROWS + 1) . $Things{ WALL, }[DISP] x COLS;
407 0         0 return $s;
408             }
409              
410             sub explode {
411 0     0 0 0 my ($something) = @_;
412 0         0 my $lmc = $something->[LMC];
413 0         0 my $pos = $lmc->[WHERE];
414 0         0 my @colors = ("\e[31m", "\e[33m");
415 0         0 for (1 .. 7) {
416 0         0 print at(map { MAP_DISP_OFF + $_ } $pos->@*), $colors[ rand @colors ], '*',
  0         0  
417             term_norm;
418 0         0 sleep($Redraw_Delay);
419             }
420 0         0 post_message('ka-boom!');
421             # HEROIC DESTRUCTION
422 0 0       0 $lmc->[GROUND] = $Things{ FLOOR, } if $lmc->[GROUND][TYPE] == STATUE;
423 0         0 push @RedrawA, $pos;
424             }
425              
426             # cribbed from some A* article on https://www.redblobgames.com/
427             sub find_hero {
428 0     0 0 0 my ($ent, $mcol, $mrow) = @_;
429              
430 0         0 my $start = $mcol . ',' . $mrow;
431 0         0 my $pcol = $Hero->[PCOL];
432 0         0 my $prow = $Hero->[PROW];
433 0         0 my $end = $pcol . ',' . $prow;
434              
435             # already waiting where the player is going to fall to
436 0 0       0 return if $start eq $end;
437              
438 0         0 my %costs = ($start => 0);
439 0         0 my %seen = ($start => undef);
440 0         0 my $q = List::PriorityQueue->new;
441 0         0 $q->insert($start, 0);
442              
443 0         0 my $linked = 0;
444 0         0 while (my $node = $q->pop) {
445 0 0       0 if ($node eq $end) {
446 0         0 $linked = 1;
447 0         0 last;
448             }
449 0         0 for my $peer ($Graphs[$Rotation]{$node}->@*) {
450 0         0 my $new = $peer->[GRAPH_NODE];
451 0         0 my $cost = $costs{$node} + $peer->[GRAPH_WEIGHT];
452 0 0 0     0 if (not exists $seen{$new} or $cost < $costs{$new}) {
453 0         0 $costs{$new} = $cost;
454             # perhaps they drove taxicabs in Manhattan in a former life?
455 0         0 my $priority =
456             $cost +
457             abs($pcol - $peer->[GRAPH_POINT][PCOL]) +
458             abs($prow - $peer->[GRAPH_POINT][PROW]);
459 0         0 $q->insert($new, $priority);
460 0         0 $seen{$new} = $node;
461             }
462             }
463             }
464 0 0       0 return unless $linked;
465              
466 0         0 my @path;
467 0         0 my $node = $end;
468 0         0 while ($node ne $start) {
469 0         0 unshift @path, $node;
470 0         0 $node = $seen{$node};
471             }
472 0         0 return [ split ',', $path[0] ];
473             }
474              
475             sub game_loop {
476 0 0   0 0 0 game_over('Terminal must be at least ' . MSG_COLS_MAX . 'x' . MSG_MAX)
477             if bad_terminal();
478 0         0 ($Level_Path, $Level, $Seed) = @_;
479 0         0 $SIG{$_} = \&bail_out for qw(INT HUP TERM PIPE QUIT USR1 USR2 __DIE__);
480 0         0 STDOUT->autoflush(1);
481 0         0 load_level();
482 0         0 ReadMode 'raw';
483 0         0 print term_norm, alt_screen, hide_cursor, hide_pointer, clear_screen,
484             draw_level;
485 0         0 post_message('The Platforms of Peril');
486 0         0 post_message('');
487 0         0 post_message('Your constant foes, the ' . properly_plural($Monst_Name));
488 0         0 post_message('seek to destroy your way of life!');
489 0         0 post_help();
490 0         0 post_message('');
491 0         0 post_message('Seed ' . $Seed . ' of version ' . $VERSION);
492 0         0 $SIG{CONT} = \&redraw_level;
493             $SIG{WINCH} = sub {
494 0 0   0   0 post_message('The terminal is too small!') if bad_terminal();
495 0         0 redraw_level();
496 0         0 };
497              
498 0         0 while (1) {
499 0         0 apply_gravity();
500 0         0 @Animates = grep { !$_->[BLACK_SPOT] } @Animates;
  0         0  
501 0 0       0 redraw_movers() if @RedrawA;
502 0 0       0 next if $Animates[HERO][UPDATE]->() == MOVE_NEWLVL;
503 0         0 track_hero();
504 0         0 for my $ent (@Animates[ 1 .. $#Animates ]) {
505 0 0 0     0 $ent->[UPDATE]->($ent) if !$ent->[BLACK_SPOT] and defined $ent->[UPDATE];
506             }
507 0         0 @Animates = grep { !$_->[BLACK_SPOT] } @Animates;
  0         0  
508 0         0 redraw_movers();
509             }
510             }
511              
512             sub game_over {
513 0     0 0 0 my ($msg, $code) = @_;
514 0   0     0 $code //= 1;
515 0         0 restore_term();
516 0         0 print clear_right, $msg, ' (', $Animates[HERO][STASH][GEM_STASH],
517             " gems)\n", clear_right;
518 0         0 exit $code;
519             }
520              
521 0     0 0 0 sub game_over_bomb { game_over('You gone done blowed yourself up.') }
522              
523             sub game_over_monster {
524 0     0 0 0 game_over('The ' . $Monst_Name . ' polished you off.');
525             }
526              
527             sub grab_gem {
528 0     0 0 0 my ($ent, $gem) = @_;
529 0         0 $ent->[STASH][GEM_STASH] += $gem->[STASH];
530 0         0 kill_animate($gem);
531 0 0       0 if ($ent->[WHAT] == MONST) {
532 0         0 post_message('The ' . $Monst_Name . ' grabs a gem.');
533             } else {
534 0         0 post_message('You now have ' . $ent->[STASH][GEM_STASH] . ' gems.');
535             }
536             }
537              
538             sub graph_bilink {
539 0     0 0 0 my ($g, $c1, $r1, $c2, $r2) = @_;
540 0         0 my $from = $c1 . ',' . $r1;
541 0         0 my $to = $c2 . ',' . $r2;
542 0         0 push $g->{$from}->@*, [ $to, 1, [ $c2, $r2 ] ];
543 0         0 push $g->{$to}->@*, [ $from, 1, [ $c1, $r1 ] ];
544             }
545              
546             sub graph_setup {
547 0     0 0 0 my $g = {};
548 0         0 for my $r (0 .. ROWS - 2) {
549 0         0 for my $c (0 .. COLS - 1) {
550 0 0       0 next if $LMap->[$r][$c][GROUND][WHAT] == WALL;
551             # allow left/right, if ladder or wall below permits it
552 0 0 0     0 if ($c != COLS - 1
      0        
      0        
      0        
553             and ( $LMap->[$r][$c][GROUND][WHAT] == LADDER
554             or $LMap->[ $r + 1 ][$c][GROUND][WHAT] == WALL)
555             and (
556             $LMap->[$r][ $c + 1 ][GROUND][WHAT] == LADDER
557             or ( $LMap->[$r][ $c + 1 ][GROUND][WHAT] != WALL
558             and $LMap->[ $r + 1 ][ $c + 1 ][GROUND][WHAT] == WALL)
559             )
560             ) {
561 0         0 graph_bilink($g, $c, $r, $c + 1, $r);
562             }
563 0 0       0 if ($r > 0) {
564             # allow motion up/down ladders
565 0 0 0     0 if ( $LMap->[$r][$c][GROUND][WHAT] == LADDER
    0 0        
      0        
566             and $LMap->[ $r - 1 ][$c][GROUND][WHAT] == LADDER) {
567 0         0 graph_bilink($g, $c, $r, $c, $r - 1);
568             } elsif (
569             $LMap->[$r][$c][GROUND][WHAT] == LADDER
570             or ( $LMap->[$r][$c][GROUND][WHAT] == FLOOR
571             and $LMap->[ $r + 1 ][$c][GROUND][WHAT] == WALL)
572             ) {
573             # can we fall into this cell from above?
574 0         0 graph_shaft($g, $c, $r);
575             }
576             }
577             }
578             }
579 0         0 for my $c (0 .. COLS - 1) {
580 0 0       0 next if $LMap->[ ROWS - 1 ][$c][GROUND][WHAT] == WALL;
581 0 0 0     0 if ( $LMap->[ ROWS - 1 ][$c][GROUND][WHAT] == LADDER
582             and $LMap->[ ROWS - 2 ][$c][GROUND][WHAT] == LADDER) {
583 0         0 graph_bilink($g, $c, ROWS - 1, $c, ROWS - 2);
584             } else {
585 0         0 graph_shaft($g, $c, ROWS - 1);
586             }
587 0 0       0 if ($c != COLS - 1) {
588 0         0 graph_bilink($g, $c, ROWS - 1, $c + 1, ROWS - 1);
589             }
590             }
591 0         0 return $g;
592             }
593              
594             sub graph_shaft {
595 0     0 0 0 my ($g, $c, $r) = @_;
596 0         0 for my $x (reverse 0 .. $r - 1) {
597 0 0       0 last if $LMap->[$x][$c][GROUND][WHAT] == WALL;
598 0         0 my $weight = $r - $x;
599 0 0       0 if ($LMap->[$x][$c][GROUND][WHAT] == LADDER) {
600 0 0       0 if ($weight == 1) {
601 0         0 graph_udlink($g, $c, $x, $c, $r, 1, [ $c, $x ]);
602             } else {
603 0         0 graph_udlink($g, $c, $x, $c, $x + 1, 1, [ $c, $x ]);
604 0         0 graph_udlink($g, $c, $x + 1, $c, $r, $weight - 2, [ $c, $r ]);
605             }
606 0         0 last;
607             }
608             # can fall into this shaft from the left or right?
609 0 0 0     0 if ($c != 0
      0        
610             and (
611             $LMap->[$x][ $c - 1 ][GROUND][WHAT] == LADDER
612             or ( $LMap->[$x][ $c - 1 ][GROUND][WHAT] == FLOOR
613             and $LMap->[ $x + 1 ][ $c - 1 ][GROUND][WHAT] == WALL)
614             )
615             ) {
616 0         0 graph_udlink($g, $c - 1, $x, $c, $x, 1, [ $c, $x ]);
617 0         0 graph_udlink($g, $c, $x, $c, $r, $weight - 1, [ $c, $r ]);
618             }
619 0 0 0     0 if ($c != COLS - 1
      0        
620             and (
621             $LMap->[$x][ $c + 1 ][GROUND][WHAT] == LADDER
622             or ( $LMap->[$x][ $c + 1 ][GROUND][WHAT] == FLOOR
623             and $LMap->[ $x + 1 ][ $c + 1 ][GROUND][WHAT] == WALL)
624             )
625             ) {
626 0         0 graph_udlink($g, $c + 1, $x, $c, $x, $weight, [ $c, $x ]);
627 0         0 graph_udlink($g, $c, $x, $c, $r, $weight - 1, [ $c, $r ]);
628             }
629             }
630             }
631              
632             sub graph_udlink {
633 0     0 0 0 my ($g, $c1, $r1, $c2, $r2, $weight, $point) = @_;
634 0         0 my $from = $c1 . ',' . $r1;
635 0         0 my $to = $c2 . ',' . $r2;
636 0         0 push $g->{$from}->@*, [ $to, $weight, $point ];
637             }
638              
639             sub interact {
640 0     0 0 0 my ($mover, $dest) = @_;
641 0         0 for my $i (ANI, ITEM) {
642 0         0 my $target = $LMap->[ $dest->[PROW] ][ $dest->[PCOL] ][$i];
643 0 0       0 if (defined $target) {
644             # this code is assumed to take care of everything and be the
645             # final say on the interaction
646 0         0 $Interact_With{ $target->[WHAT] }->($mover, $target);
647 0         0 return 1;
648             }
649             }
650 0         0 return 0;
651             }
652              
653             sub kill_animate {
654 0     0 0 0 my ($ent, $no_draw) = @_;
655 0 0       0 push @RedrawA, $ent->[LMC][WHERE] unless defined $no_draw;
656 0         0 $ent->[BLACK_SPOT] = 1;
657             # NOTE this only works for TYPE of ANI or ITEM, may need to rethink
658             # how STATUE and STAIRS are handled if there are GROUND checks on
659             # TYPE as those abuse the TYPE field for other things (see %Things)
660 0         0 undef $ent->[LMC][ $ent->[TYPE] ];
661             }
662              
663             sub load_level {
664 0     0 0 0 my $file = catfile($Level_Path, 'level' . $Level++);
665 0 0       0 game_over('No more levels.', 0) unless -e $file;
666              
667 0 0       0 open(my $fh, '<', $file) or game_over("Failed to open '$file': $!");
668              
669 0         0 splice @Animates, 1;
670 0         0 undef $Animates[HERO][LMC];
671 0         0 $LMap = [];
672              
673 0         0 my $rownum = 0;
674 0         0 while (my $line = readline $fh) {
675 0         0 chomp $line;
676 0 0       0 game_over("Wrong number of columns at $file:$.") if length $line != COLS;
677 0         0 my $colnum = 0;
678 0         0 for my $v (split //, $line) {
679 0   0     0 my $c = $CharMap{$v} // game_over("Unknown character $v at $file:$.");
680 0         0 my $point = [ $colnum++, $rownum ]; # PCOL, PROW (x, y)
681 0 0       0 if (exists $Things{$c}) {
682 0 0       0 if ($c eq BOMB) {
    0          
683 0         0 push $LMap->[$rownum]->@*, [ $point, $Things{ FLOOR, } ];
684 0         0 make_item($point, BOMB, 0);
685             } elsif ($c eq GEM) {
686 0         0 push $LMap->[$rownum]->@*, [ $point, $Things{ FLOOR, } ];
687 0         0 make_item($point, GEM, GEM_VALUE);
688             } else {
689 0         0 push $LMap->[$rownum]->@*, [ $point, $Things{$c} ];
690             }
691             } else {
692 0 0       0 if ($c eq HERO) {
    0          
693 0 0       0 game_over("Player placed twice in $file")
694             if defined $Animates[HERO][LMC];
695             push $LMap->[$rownum]->@*,
696 0         0 [ $point, $Things{ FLOOR, }, undef, $Animates[HERO] ];
697 0         0 $Animates[HERO][LMC] = $LMap->[$rownum][-1];
698 0         0 $Hero = $point;
699 0         0 weaken($Animates[HERO][LMC]);
700             } elsif ($c eq MONST) {
701 0         0 push $LMap->[$rownum]->@*, [ $point, $Things{ FLOOR, } ];
702 0         0 make_monster($point);
703             } else {
704 0         0 game_over("Unknown object '$v' at $file:$.");
705             }
706             }
707             }
708 0 0       0 last if ++$rownum == ROWS;
709             }
710 0 0       0 game_over("Too few rows in $file") if $rownum < ROWS;
711 0 0       0 game_over("No player in $file") unless defined $Animates[HERO][LMC];
712              
713 0         0 $Rotation = 0;
714 0         0 for my $rot (1 .. 4) {
715 0         0 $Graphs[$Rotation] = graph_setup();
716 0         0 rotate_left();
717             }
718             }
719              
720             sub make_item {
721 0     0 0 0 my ($point, $thingy, $stash, $update) = @_;
722 0         0 my $item;
723             $item->@[ WHAT, DISP, TYPE, STASH, UPDATE, LMC ] = (
724 0         0 $Things{$thingy}->@*,
725             $stash, $update, $LMap->[ $point->[PROW] ][ $point->[PCOL] ]
726             );
727 0         0 push @Animates, $item;
728 0         0 $LMap->[ $point->[PROW] ][ $point->[PCOL] ][ITEM] = $item;
729 0         0 weaken($item->[LMC]);
730             }
731              
732             sub make_monster {
733 0     0 0 0 my ($point) = @_;
734 0         0 my $monst;
735 0         0 my $ch = substr $Monst_Name, 0, 1;
736             # STASH replicates that of the HERO for simpler GEM handling code
737             # though the BOMB_STASH is instead used for GEM_ODDS
738 0         0 $monst->@[ WHAT, DISP, TYPE, STASH, UPDATE, LMC ] = (
739             MONST, "\e[1;33m$ch\e[0m", ANI, [ 0, 0.0 ],
740             \&update_monst, $LMap->[ $point->[PROW] ][ $point->[PCOL] ]
741             );
742 0         0 push @Animates, $monst;
743 0         0 $LMap->[ $point->[PROW] ][ $point->[PCOL] ][ANI] = $monst;
744 0         0 weaken($monst->[LMC]);
745             }
746              
747             sub move_animate {
748 0     0 0 0 my ($ent, $cols, $rows) = @_;
749 0         0 my $lmc = $ent->[LMC];
750              
751 0         0 my $from = $lmc->[WHERE][PCOL] . ',' . $lmc->[WHERE][PROW];
752 0         0 my $to =
753             ($lmc->[WHERE][PCOL] + $cols) . ',' . ($lmc->[WHERE][PROW] + $rows);
754              
755             return MOVE_FAILED
756 0 0   0   0 unless first { $_->[GRAPH_NODE] eq $to } $Graphs[$Rotation]{$from}->@*;
  0         0  
757              
758 0         0 my $dest = [ $lmc->[WHERE][PCOL] + $cols, $lmc->[WHERE][PROW] + $rows ];
759              
760 0 0       0 relocate($ent, $dest) unless interact($ent, $dest);
761 0         0 return MOVE_OK;
762             }
763              
764             # so the player can see if there is a ladder under something; this is an
765             # important consideration on some levels
766             sub move_examine {
767 0     0 0 0 my $key;
768 0         0 my $row = $Animates[HERO][LMC][WHERE][PROW];
769 0         0 my $col = $Animates[HERO][LMC][WHERE][PCOL];
770 0         0 print at(MSG_COL, MSG_ROW + $_), clear_right for 1 .. MSG_MAX;
771 0         0 print at(MSG_COL, MSG_ROW), clear_right,
772             'Move cursor to view a cell. Esc exits', show_cursor;
773 0         0 while (1) {
774 0         0 print at(MSG_COL, MSG_ROW + $_), clear_right for 3 .. 5;
775 0         0 my $disp_row = 2;
776 0         0 for my $i (ANI, ITEM) {
777 0         0 my $x = $LMap->[$row][$col][$i];
778 0 0       0 if (defined $x) {
779             print at(MSG_COL, MSG_ROW + $disp_row++), clear_right, $x->[DISP],
780 0         0 ' - ', $Descriptions{ $x->[WHAT] };
781             }
782             }
783 0         0 my $g = $LMap->[$row][$col][GROUND];
784             print at(MSG_COL, MSG_ROW + $disp_row), clear_right, $g->[DISP],
785 0         0 ' - ', $Descriptions{ $g->[TYPE] },
786             at(MAP_DISP_OFF + $col, MAP_DISP_OFF + $row);
787 0         0 $key = ReadKey(0);
788 0 0       0 last if $key eq "\033";
789 0         0 my $distance = 1;
790 0 0       0 if (ord $key < 97) { # SHIFT moves faster
791 0         0 $key = lc $key;
792 0         0 $distance = 5;
793             }
794 0   0     0 my $dir = $Examine_Offsets{$key} // next;
795 0         0 $row = between(0, ROWS - 1, $row + $dir->[PROW] * $distance);
796 0         0 $col = between(0, COLS - 1, $col + $dir->[PCOL] * $distance);
797             }
798 0         0 print hide_cursor;
799 0         0 show_messages();
800 0         0 return MOVE_FAILED;
801             }
802              
803 0     0 0 0 sub move_nop { return MOVE_OK }
804              
805             sub move_player {
806 4     4 0 6 my ($cols, $rows) = @_;
807             return sub {
808 0     0     my ($status, $msg) = move_animate($Animates[HERO], $cols, $rows);
809 0 0         post_message($msg) if $msg;
810 0           return $status;
811 4         39 };
812             }
813              
814             sub post_help {
815 0     0 0   my $ch = substr $Monst_Name, 0, 1;
816 0           post_message('');
817 0           post_message(
818             ' ' . $Animates[HERO][DISP] . ' - You ' . $ch . ' - a ' . $Monst_Name);
819             post_message(
820 0           ' ' . $Things{ STATUE, }[DISP] . ' - a large granite statue done in the');
821 0           post_message(' ' . $Style . ' style');
822             post_message(' '
823             . $Things{ BOMB, }[DISP]
824             . ' - Bomb '
825 0           . $Things{ GEM, }[DISP]
826             . ' - Gem (get these)');
827 0           post_message('');
828 0           post_message(' h j k l - move');
829 0           post_message(' < > - activate left or right boot');
830 0           post_message(' B - drop a Bomb');
831 0           post_message(' M - make a Bomb (consumes ' . BOMB_COST . ' Gems)');
832             post_message(
833 0           ' % - when on ' . $Things{ STAIR, }[DISP] . ' goes to the next level');
834 0           post_message(' . space - pass a turn (handy when falling)');
835 0           post_message('');
836 0           post_message(' q - quit the game (no save)');
837 0           post_message(' $ - display Bomb and Gem counts');
838 0           post_message(' ? - post these help messages');
839 0           post_message('');
840 0           post_message('You have '
841             . $Animates[HERO][STASH][BOMB_STASH]
842             . ' bombs and '
843             . $Animates[HERO][STASH][GEM_STASH]
844             . ' gems.');
845             }
846              
847             {
848             my @log;
849              
850             sub post_message {
851 0     0 0   my ($msg) = @_;
852 0           while (@log >= MSG_MAX) { shift @log }
  0            
853 0           push @log, $msg;
854 0           show_messages();
855             }
856 0     0 0   sub clear_messages { @log = () }
857              
858             sub show_messages {
859 0     0 0   for my $i (0 .. $#log) {
860 0           print at(MSG_COL, MSG_ROW + $i), clear_right, $log[$i];
861             }
862             }
863             }
864              
865             # fsvo properly... damnit Jim I'm a sysadmin not a linguist
866             sub properly_plural {
867 0     0 0   my ($name) = @_;
868 0 0         $name =~ s/oo/ee/ ? $name : $name . 's';
869             }
870              
871 0     0 0   sub redraw_level { print clear_screen, draw_level; show_messages() }
  0            
872              
873             sub redraw_movers {
874 0     0 0   redraw_ref(\@RedrawA);
875 0           sleep($Redraw_Delay);
876 0           redraw_ref(\@RedrawB);
877 0           @RedrawA = ();
878 0           @RedrawB = ();
879             }
880              
881             sub redraw_ref {
882 0     0 0   CELL: for my $point ($_[0]->@*) {
883 0           for my $i (ANI, ITEM) {
884 0           my $ent = $LMap->[ $point->[PROW] ][ $point->[PCOL] ][$i];
885 0 0 0       if (defined $ent and !$ent->[BLACK_SPOT]) {
886 0           print at(map { MAP_DISP_OFF + $_ } $point->@*), $ent->[DISP];
  0            
887 0           next CELL;
888             }
889             }
890 0           print at(map { MAP_DISP_OFF + $_ } $point->@*),
  0            
891             $LMap->[ $point->[PROW] ][ $point->[PCOL] ][GROUND][DISP];
892             }
893             }
894              
895             sub relocate {
896 0     0 0   my ($ent, $dest) = @_;
897 0           my $src = $ent->[LMC][WHERE];
898 0           push @RedrawA, $src;
899 0           push @RedrawB, $dest;
900 0           my $lmc = $LMap->[ $dest->[PROW] ][ $dest->[PCOL] ];
901 0           $lmc->[ $ent->[TYPE] ] = $ent;
902 0           undef $LMap->[ $src->[PROW] ][ $src->[PCOL] ][ $ent->[TYPE] ];
903 0           $ent->[LMC] = $lmc;
904 0           weaken($ent->[LMC]);
905             }
906              
907             sub restore_term {
908 0     0 0   ReadMode 'restore';
909 0           print term_norm, show_cursor, unalt_screen;
910             }
911              
912             sub rotate_left {
913 0     0 0   my $lm;
914 0           for my $r (0 .. ROWS - 1) {
915 0           for my $c (0 .. COLS - 1) {
916 0           my $newr = COLS - 1 - $c;
917 0           $lm->[$newr][$r] = $LMap->[$r][$c];
918 0           $lm->[$newr][$r][WHERE] = [ $r, $newr ];
919             }
920             }
921 0           $LMap = $lm;
922 0           $Rotation = ($Rotation + 1) % 4;
923             }
924              
925             sub rotate_right {
926 0     0 0   my $lm;
927 0           for my $r (0 .. ROWS - 1) {
928 0           for my $c (0 .. COLS - 1) {
929 0           my $newc = ROWS - 1 - $r;
930 0           $lm->[$c][$newc] = $LMap->[$r][$c];
931 0           $lm->[$c][$newc][WHERE] = [ $newc, $c ];
932             }
933             }
934 0           $LMap = $lm;
935 0           $Rotation = ($Rotation - 1) % 4;
936             }
937              
938             sub track_hero {
939 0     0 0   $Hero = $Animates[HERO][LMC][WHERE];
940              
941             # route monsters to where the player will fall to as otherwise they
942             # tend to freeze or head in the wrong direction
943 0           my $row = $Hero->[PROW];
944 0           my $col = $Hero->[PCOL];
945 0 0 0       return if $row == ROWS - 1 or $LMap->[$row][$col][GROUND][WHAT] == LADDER;
946              
947 0           my $goal = $row;
948 0           for my $r ($row + 1 .. ROWS - 1) {
949 0 0         last if $LMap->[$r][$col][GROUND][WHAT] == WALL;
950 0 0 0       if ($LMap->[$r][$col][GROUND][WHAT] == LADDER
      0        
      0        
      0        
      0        
951             or ( $r < ROWS - 2
952             and $LMap->[$r][$col][GROUND][WHAT] == FLOOR
953             and $LMap->[ $r + 1 ][$col][GROUND][WHAT] == WALL)
954             or ( $r == ROWS - 1
955             and $LMap->[$r][$col][GROUND][WHAT] == FLOOR)
956             ) {
957 0           $goal = $r;
958 0           last;
959             }
960             }
961 0           $Hero = [ $col, $goal ];
962             }
963              
964             sub update_hero {
965 0     0 0   my ($key, $ret);
966 0           tcflush(STDIN_FILENO, TCIFLUSH);
967 0           while (1) {
968 0           while (1) {
969 0           $key = ReadKey(0);
970 0 0         last if exists $Key_Commands{$key};
971 0           post_message(sprintf "Illegal command \\%03o", ord $key);
972             }
973 0           ($ret, my $msg) = $Key_Commands{$key}->();
974 0 0         post_message($msg) if defined $msg;
975 0 0 0       last if $ret == MOVE_OK or $ret == MOVE_NEWLVL;
976             }
977 0           return $ret;
978             }
979              
980             sub update_monst {
981 0     0 0   my ($ent) = @_;
982 0           my $mcol = $ent->[LMC][WHERE][PCOL];
983 0           my $mrow = $ent->[LMC][WHERE][PROW];
984              
985             # prevent monster move where only gravity should apply
986             # NOTE one may have the clever idea that monsters can run across the
987             # heads of other monsters though that would require changes to how
988             # the graph is setup to permit such moves, and additional checks to
989             # see if something to tread upon is available (and then to let the
990             # hero do that (like in Lode Runner) or to prevent them from such
991             # head-running...)
992 0 0 0       if ( $mrow != ROWS - 1
      0        
993             and $ent->[LMC][GROUND][WHAT] == FLOOR
994             and $LMap->[ $mrow + 1 ][$mcol][GROUND][WHAT] != WALL) {
995 0           return;
996             }
997              
998 0           my $dest = find_hero($ent, $mcol, $mrow);
999 0 0         return unless defined $dest;
1000              
1001 0 0         relocate($ent, $dest) unless interact($ent, $dest);
1002              
1003 0 0 0       if ($ent->[STASH][GEM_STASH] > 0
1004             and !defined $ent->[LMC][ITEM]) {
1005 0 0         if (rand() < $ent->[STASH][GEM_ODDS]) {
1006 0           post_message('The ' . $Monst_Name . ' drops a gem!');
1007 0           $ent->[STASH][GEM_STASH]--;
1008 0           make_item($ent->[LMC][WHERE], GEM, GEM_VALUE);
1009 0           $ent->[STASH][GEM_ODDS] = 0.0 - GEM_ODDS_ADJUST;
1010             }
1011 0           $ent->[STASH][GEM_ODDS] += GEM_ODDS_ADJUST;
1012             }
1013             }
1014              
1015             1;
1016             __END__