File Coverage

blib/lib/Game/PlatformsOfPeril.pm
Criterion Covered Total %
statement 28 395 7.0
branch 0 132 0.0
condition 0 91 0.0
subroutine 10 56 17.8
pod 0 43 0.0
total 38 717 5.3


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