File Coverage

blib/lib/Game/PlatformsOfPeril.pm
Criterion Covered Total %
statement 28 390 7.1
branch 0 130 0.0
condition 0 91 0.0
subroutine 10 55 18.1
pod 0 42 0.0
total 38 708 5.3


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