—# -*- Perl -*-
#
# raycast and shadowcast field-of-view and related routines (see also
# the *.xs file)
package
Game::RaycastFOV;
our
$VERSION
=
'2.03'
;
use
strict;
use
warnings;
require
XSLoader;
our
@EXPORT_OK
=
qw(bypair bypairall cached_circle circle line raycast shadowcast sub_circle swing_circle %circle_points)
;
XSLoader::load(
'Game::RaycastFOV'
,
$VERSION
);
# precomputed via swing_circle(). only up to 11 due to 80x24 terminal.
# can be added to or changed as desired by caller. one could for example
# have a 0 radius that only fills in the compass directions adjacent or
# other shapes suitable to the need at hand
#
# NOTE these may change to be more efficient at doing a minimally
# complete raycast instead of the complete exterior circle (which
# probably creates more raycasts than may be necessary)
our
%circle_points
= (
1
=> [ 1, 0, 1, 1, 0, 1, -1, 1, -1, 0, -1, -1, 0, -1, 1, -1 ],
2
=> [
2, 0, 2, 1, 1, 1, 1, 2, 0, 2, -1, 2, -1, 1, -2, 1,
-2, 0, -2, -1, -1, -1, -1, -2, 0, -2, 1, -2, 1, -1, 2, -1
],
3
=> [
3, 0, 3, 1, 2, 1, 2, 2, 1, 2, 1, 3, 0, 3, -1, 3,
-1, 2, -2, 2, -2, 1, -3, 1, -3, 0, -3, -1, -2, -1, -2, -2,
-1, -2, -1, -3, 0, -3, 1, -3, 1, -2, 2, -2, 2, -1, 3, -1
],
4
=> [
4, 0, 4, 1, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 1, 4,
0, 4, -1, 4, -2, 4, -2, 3, -3, 3, -3, 2, -4, 2, -4, 1,
-4, 0, -4, -1, -4, -2, -3, -2, -3, -3, -2, -3, -2, -4, -1, -4,
0, -4, 1, -4, 2, -4, 2, -3, 3, -3, 3, -2, 4, -2, 4, -1
],
5
=> [
5, 0, 5, 1, 5, 2, 4, 2, 4, 3, 3, 3, 3, 4, 2, 4,
2, 5, 1, 5, 0, 5, -1, 5, -2, 5, -2, 4, -3, 4, -3, 3,
-4, 3, -4, 2, -5, 2, -5, 1, -5, 0, -5, -1, -5, -2, -4, -2,
-4, -3, -3, -3, -3, -4, -2, -4, -2, -5, -1, -5, 0, -5, 1, -5,
2, -5, 2, -4, 3, -4, 3, -3, 4, -3, 4, -2, 5, -2, 5, -1
],
6
=> [
6, 0, 6, 1, 6, 2, 5, 2, 5, 3, 5, 4, 4, 4, 4, 5,
3, 5, 2, 5, 2, 6, 1, 6, 0, 6, -1, 6, -2, 6, -2, 5,
-3, 5, -4, 5, -4, 4, -5, 4, -5, 3, -5, 2, -6, 2, -6, 1,
-6, 0, -6, -1, -6, -2, -5, -2, -5, -3, -5, -4, -4, -4, -4, -5,
-3, -5, -2, -5, -2, -6, -1, -6, 0, -6, 1, -6, 2, -6, 2, -5,
3, -5, 4, -5, 4, -4, 5, -4, 5, -3, 5, -2, 6, -2, 6, -1
],
7
=> [
7, 0, 7, 1, 7, 2, 6, 2, 6, 3, 6, 4, 5, 4, 5, 5,
4, 5, 4, 6, 3, 6, 2, 6, 2, 7, 1, 7, 0, 7, -1, 7,
-2, 7, -2, 6, -3, 6, -4, 6, -4, 5, -5, 5, -5, 4, -6, 4,
-6, 3, -6, 2, -7, 2, -7, 1, -7, 0, -7, -1, -7, -2, -6, -2,
-6, -3, -6, -4, -5, -4, -5, -5, -4, -5, -4, -6, -3, -6, -2, -6,
-2, -7, -1, -7, 0, -7, 1, -7, 2, -7, 2, -6, 3, -6, 4, -6,
4, -5, 5, -5, 5, -4, 6, -4, 6, -3, 6, -2, 7, -2, 7, -1
],
8
=> [
8, 0, 8, 1, 8, 2, 7, 2, 7, 3, 7, 4, 6, 4, 6, 5,
6, 6, 5, 6, 4, 6, 4, 7, 3, 7, 2, 7, 2, 8, 1, 8,
0, 8, -1, 8, -2, 8, -2, 7, -3, 7, -4, 7, -4, 6, -5, 6,
-6, 6, -6, 5, -6, 4, -7, 4, -7, 3, -7, 2, -8, 2, -8, 1,
-8, 0, -8, -1, -8, -2, -7, -2, -7, -3, -7, -4, -6, -4, -6, -5,
-6, -6, -5, -6, -4, -6, -4, -7, -3, -7, -2, -7, -2, -8, -1, -8,
0, -8, 1, -8, 2, -8, 2, -7, 3, -7, 4, -7, 4, -6, 5, -6,
6, -6, 6, -5, 6, -4, 7, -4, 7, -3, 7, -2, 8, -2, 8, -1
],
9
=> [
9, 0, 9, 1, 9, 2, 9, 3, 8, 3, 8, 4, 8, 5, 7, 5, 7, 6,
6, 6, 6, 7, 5, 7, 5, 8, 4, 8, 3, 8, 3, 9, 2, 9, 1, 9,
0, 9, -1, 9, -2, 9, -3, 9, -3, 8, -4, 8, -5, 8, -5, 7, -6, 7,
-6, 6, -7, 6, -7, 5, -8, 5, -8, 4, -8, 3, -9, 3, -9, 2, -9, 1,
-9, 0, -9, -1, -9, -2, -9, -3, -8, -3, -8, -4, -8, -5, -7, -5, -7, -6,
-6, -6, -6, -7, -5, -7, -5, -8, -4, -8, -3, -8, -3, -9, -2, -9, -1, -9,
0, -9, 1, -9, 2, -9, 3, -9, 3, -8, 4, -8, 5, -8, 5, -7, 6, -7,
6, -6, 7, -6, 7, -5, 8, -5, 8, -4, 8, -3, 9, -3, 9, -2, 9, -1
],
10
=> [
10, 0, 10, 1, 10, 2, 10, 3, 9, 3, 9, 4, 9, 5,
8, 5, 8, 6, 7, 6, 7, 7, 6, 7, 6, 8, 5, 8,
5, 9, 4, 9, 3, 9, 3, 10, 2, 10, 1, 10, 0, 10,
-1, 10, -2, 10, -3, 10, -3, 9, -4, 9, -5, 9, -5, 8,
-6, 8, -6, 7, -7, 7, -7, 6, -8, 6, -8, 5, -9, 5,
-9, 4, -9, 3, -10, 3, -10, 2, -10, 1, -10, 0, -10, -1,
-10, -2, -10, -3, -9, -3, -9, -4, -9, -5, -8, -5, -8, -6,
-7, -6, -7, -7, -6, -7, -6, -8, -5, -8, -5, -9, -4, -9,
-3, -9, -3, -10, -2, -10, -1, -10, 0, -10, 1, -10, 2, -10,
3, -10, 3, -9, 4, -9, 5, -9, 5, -8, 6, -8, 6, -7,
7, -7, 7, -6, 8, -6, 8, -5, 9, -5, 9, -4, 9, -3,
10, -3, 10, -2, 10, -1
],
11
=> [
11, 0, 11, 1, 11, 2, 11, 3, 10, 3, 10, 4, 10, 5,
9, 5, 9, 6, 9, 7, 8, 7, 8, 8, 7, 8, 7, 9,
6, 9, 5, 9, 5, 10, 4, 10, 3, 10, 3, 11, 2, 11,
1, 11, 0, 11, -1, 11, -2, 11, -3, 11, -3, 10, -4, 10,
-5, 10, -5, 9, -6, 9, -7, 9, -7, 8, -8, 8, -8, 7,
-9, 7, -9, 6, -9, 5, -10, 5, -10, 4, -10, 3, -11, 3,
-11, 2, -11, 1, -11, 0, -11, -1, -11, -2, -11, -3, -10, -3,
-10, -4, -10, -5, -9, -5, -9, -6, -9, -7, -8, -7, -8, -8,
-7, -8, -7, -9, -6, -9, -5, -9, -5, -10, -4, -10, -3, -10,
-3, -11, -2, -11, -1, -11, 0, -11, 1, -11, 2, -11, 3, -11,
3, -10, 4, -10, 5, -10, 5, -9, 6, -9, 7, -9, 7, -8,
8, -8, 8, -7, 9, -7, 9, -6, 9, -5, 10, -5, 10, -4,
10, -3, 11, -3, 11, -2, 11, -1
]
);
# the lack of checks are for speed, use at your own risk
sub
cached_circle (&$$$) {
my
(
$callback
,
$x
,
$y
,
$radius
) =
@_
;
# process all the points on the assumption that the callback will
# abort say line drawing should that wander outside a level map
bypairall(
sub
{
$callback
->(
$x
+
$_
[0],
$y
+
$_
[1] ) },
@{
$circle_points
{
$radius
} } );
}
sub
raycast {
my
(
$circle_cb
,
$line_cb
,
$x
,
$y
,
@rest
) =
@_
;
$circle_cb
->(
sub
{ line(
$line_cb
,
$x
,
$y
,
$_
[0],
$_
[1] ) },
$x
,
$y
,
@rest
);
}
# or in particular the Java and Ruby implementations
sub
shadowcast {
my
(
$startx
,
$starty
,
$radius
,
$bcb
,
$lcb
,
$rcb
) =
@_
;
$lcb
->(
$startx
,
$starty
, 0, 0 );
for
my
$mult
(
[ 1, 0, 0, 1 ],
[ 0, 1, 1, 0 ],
[ 0, -1, 1, 0 ],
[ -1, 0, 0, 1 ],
[ -1, 0, 0, -1 ],
[ 0, -1, -1, 0 ],
[ 0, 1, -1, 0 ],
[ 1, 0, 0, -1 ]
) {
_shadowcast(
$startx
,
$starty
,
$radius
,
$bcb
,
$lcb
,
$rcb
, 1, 1.0, 0.0,
@$mult
);
}
}
sub
_shadowcast {
my
(
$startx
,
$starty
,
$radius
,
$bcb
,
$lcb
,
$rcb
,
$row
,
$light_start
,
$light_end
,
$xx
,
$xy
,
$yx
,
$yy
)
=
@_
;
my
$blocked
= 0;
my
$new_start
= 0.0;
for
my
$j
(
$row
..
$radius
) {
my
$dy
= -
$j
;
for
my
$dx
(
$dy
.. 0 ) {
my
$rslope
= (
$dx
+ 0.5 ) / (
$dy
- 0.5 );
my
$lslope
= (
$dx
- 0.5 ) / (
$dy
+ 0.5 );
if
(
$light_start
<
$rslope
) {
next
}
elsif
(
$light_end
>
$lslope
) {
last
}
my
$curx
=
$startx
+
$dx
*
$xx
+
$dy
*
$xy
;
my
$cury
=
$starty
+
$dx
*
$yx
+
$dy
*
$yy
;
$lcb
->(
$curx
,
$cury
,
$dx
,
$dy
)
if
$rcb
->(
$dx
,
$dy
);
if
(
$blocked
) {
if
(
$bcb
->(
$curx
,
$cury
,
$dx
,
$dy
) ) {
$new_start
=
$rslope
;
next
;
}
else
{
$blocked
= 0;
$light_start
=
$new_start
;
}
}
else
{
if
(
$bcb
->(
$curx
,
$cury
,
$dx
,
$dy
) and
$j
<
$radius
) {
$blocked
= 1;
_shadowcast(
$startx
,
$starty
,
$radius
,
$bcb
,
$lcb
,
$rcb
,
$j
+ 1,
$light_start
,
$lslope
,
$xx
,
$xy
,
$yx
,
$yy
)
unless
$light_start
<
$lslope
;
$new_start
=
$rslope
;
}
}
}
last
if
$blocked
;
}
}
sub
swing_circle(&$$$$) {
push
@_
, 0, pi2;
goto
&sub_circle
;
}
# for reference; converted to XS in version 2.02 with the following
# matching and updated code not being quite so stupid about rounding
# ints and thus not needing a plus 0.5 fudge factor
#sub swing_circle (&$$$$) {
# my ( $callback, $x, $y, $radius, $swing ) = @_;
# my $angle = 0;
# my %seen;
# while ( $angle < pi2 ) {
# my $nx = $x + sprintf( "%.0f", $radius * cos $angle );
# my $ny = $y + sprintf( "%.0f", $radius * sin $angle );
# $callback->( $nx, $ny ) unless $seen{ $nx . ',' . $ny }++;
# $angle += $swing;
# }
#}
1;
__END__
=head1 NAME
Game::RaycastFOV - raycast field-of-view and related routines
=head1 SYNOPSIS
use Game::RaycastFOV qw(
bypair circle line
cached_circle swing_circle
raycast shadowcast
);
# mostly internal utility routine
bypair( { my ($x,$y) = @_; ... } $x1, $y1, $x2, $y2, ... );
# Bresenham in XS
circle( { my ($cx,$cy) = @_; ... } $x, $y, $radius );
line( { my ($lx,$ly) = @_; ... } $x, $y, $x1, $y1 );
# fast, slower circle constructions
cached_circle( { my ($cx,$cy) ... } $x, $y, $radius );
swing_circle( { my ($cx,$cy) ... } $x, $y, $radius, $swing );
# complicated, see docs and examples
raycast( \&circle, sub { ... }, $x, $y, ... );
shadowcast( ... );
=head1 DESCRIPTION
This module contains various subroutines that perform fast calculation
of lines and circles; these in turn help with Field Of View (FOV)
calculations. Raycasting and shadowcasting FOV calls are provided.
Speed is favored over error checking; the XS code may not work for large
integer values; etc.
=head2 Raycasting Explained in One Thousand Words or Less
.#.##
.##.##### #
#.##..##... #.
.##.#.##.#... #.
#####..#.#### # #.
.#.#.#.###.##.. #.#.##
####....#.##... #....#
##...#.@#T...## #.@#
#..#.###....#.# ###..
.##.#####..#... #..
.##...####.## ##.#
....#.###.#.. .
###.###.#..
.######.#
....#
Will our plucky hero stumble into that Troll unseen? Tune in next week!
=head1 FUNCTIONS
=over 4
=item B<bypair> I<callback> I<...>
Utility function for slicing up an arbitrary list pairwise. Sort of like
C<pairwise> of L<List::Util> only in a void context, and that returning
the value C<-1> from the I<callback> subroutine will abort the
processing of subsequent items.
=item B<bypairall> I<callback> I<...>
Like B<bypair> but does not include code to abort processing the list.
Since v1.01.
=item B<cached_circle> I<callback> I<x> I<y> I<radius>
This routine looks up the I<radius> in the C<%circle_points> variable
(which is available for export and can be modified as need be) to obtain
a pre-computed list of circle points (calculated by B<swing_circle>)
that are fed to the I<callback> as is done for the B<circle> call.
Will silently do nothing if the I<radius> is not found in the cache.
This is by design so that B<cached_circle> is fast.
The cached points might (but are unlikely to) change without notice;
calling code if paranoid should set specific sets of points to use or
require a specific version of this module.
=item B<circle> I<callback> I<x> I<y> I<radius>
Bresenham circle. Note that this may not produce a completely filled-in
FOV at various radius.
Since version 2.02 only unique points are passed to the I<callback>.
=item B<line> I<callback> I<x0> I<y0> I<x1> I<y1>
Bresenham line. Returning the value C<-1> from the I<callback>
subroutine will abort the processing of the line at the given point.
=item B<raycast> I<circle-fn> I<point-fn> I<x> I<y> I<...>
Given a I<circle-fn> such as B<circle> or B<swing_circle> and the center
of a circle given by I<x> and I<y>, the B<raycast> calls B<line> between
I<x>,I<y> and the points returned by the circle function; B<line> in
turn will call the user-supplied B<point-fn> to handle what should
happen at each raycasted point. Additional arguments I<...> will be
passed to the I<circle-fn> following I<x> and I<y>.
L</"EXAMPLES"> may be of more help than the above text.
=item B<shadowcast> I<x> I<y> I<radius> I<blockcb> I<litcb> I<radiuscb>
Performs a shadowcast FOV calculation of the given I<radius> around the
point I<x>, I<y>. Callbacks:
=over 4
=item *
I<blockcb> is called with I<newx>, I<newy> (the point shadowcasting has
reached), I<deltax>, and I<deltay> (the delta from the origin for the
point). It return a boolean indicating whether that coordinate is
blocked on the level map (e.g. by a wall, a large monster, or maybe the
angle from the starting point is no good, etc).
The I<deltax> and I<deltay> values are only passed in module version
2.02 or higher.
=item *
I<litcb> is called with I<newx>, I<newy>, I<deltax>, and I<deltay> and
should do whatever needs to be done to present that point as visible.
=item *
I<radiuscb> is passed I<deltax>, I<deltay>, and I<radius> and must
return true if the deltas are within the radius. This allows for
different FOV shapes. The delta values could be negative so will need to
be run through C<abs> or C<** 2> to determine the distance.
=back
B<The callbacks may be called with points outside of a level map>.
=item B<sub_circle> I<callback> I<x0> I<y0> I<radius> I<swing> I<start-angle> I<max-angle>
Finds points around the given I<radius> by rotating a ray by I<swing>
radians starting from I<start-angle> and ending at I<max-angle>. Smaller
I<swing> values will result in a more complete circle at the cost of
additional CPU and memory use. Each unique point is passed to the
I<callback> function:
sub_circle( sub { my ($newx, $newy) = @_; ... }, ... );
Has limited to no error checking; the caller should ensure that the
I<swing> value is positive, etc.
Since version 2.02.
=item B<swing_circle> I<callback> I<x0> I<y0> I<radius> I<swing>
Calls B<sub_circle> with a starting angle of C<0> and a max angle of
C<pi * 2>.
Prior to version 2.02 used distinct code.
=back
=head1 EXAMPLES
See also the C<eg/> directory of this module's distribution.
L<https://thrig.me/src/ministry-of-silly-vaults.git> has a FOV
subdirectory with example scripts.
use Game::RaycastFOV qw(circle raycast swing_circle);
use Math::Trig 'deg2rad';
# to only draw within the map area
our $MAX_X = 79;
our $MAX_Y = 23;
# assuming a rows/columns array-of-arrays with characters
our @map = ( ... );
# something that updates the @map
sub plot { ... }
# where the FOV happens and how big it is
my ($x, $y, $radius) = ...;
raycast(
\&circle, sub {
my ($lx, $ly) = @_;
# whoops line has wandered outside of map
return -1 if $lx < 0 or $lx > $MAX_X
or $ly < 0 or $ly > $MAX_Y;
# may instead build up a string to print to terminal
my $ch = $map[$ly][$lx];
plot($lx, $ly, $ch);
# abort the line if FOV is blocked
return -1 if $ch eq '#';
}, $x, $y, $radius
);
# or instead using swing_circle
raycast(
\&swing_circle, sub {
my ($lx, $ly) = @_;
return -1 if $lx < 0 or $lx > $MAX_X
or $ly < 0 or $ly > $MAX_Y;
my $ch = $map[$ly][$lx];
plot($lx, $ly, $ch);
return -1 if $ch eq '#';
}, $x, $y, $radius, deg2rad(5) # different arguments!
);
The B<plot> routine may need to cache whether something has been printed
to the given cell as B<raycast> likes to revisit cells a lot, especially
those close to the origin that are clear of FOV-blocking obstacles.
=head1 BUGS
None known. Raycast is problematic, but that's probably baked into to
algorithm, which is why other methods of FOV were invented.
=head1 SEE ALSO
L<Game::Xomb> uses modified code from this module.
L<NetHack::FOV>
There are other FOV algorithms and implementations to be found on
the Internet.
=head1 COPYRIGHT AND LICENSE
This software is Copyright (c) 2020 by Jeremy Mates.
This is free software, licensed under:
The (three-clause) BSD License
=cut