The Perl Toolchain Summit 2025 Needs You: You can help 🙏 Learn more

# -*- Perl -*-
#
# raycast and shadowcast field-of-view and related routines (see also
# the *.xs file)
our $VERSION = '2.03';
use strict;
use Math::Trig ':pi';
require XSLoader;
use base qw(Exporter);
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.
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