Field of View

This Week's Music is a spookier track, since this week is all about not knowing what's out there.

Reddit

This week we will add field of view to our game, including having the game remember where the player has explored or not, so when drawing we can distinguish between currently seen, previously seen, and never seen locations. (It later came to my attention that the reddit post also has beginning monsters this week, but it's cleaner to have all FOV this week and all monsters next week so that's how we're doing it).

The particular Field of View (FOV) flavor that we'll be using is Precise Permissive Field of View. Specifically, we'll be using the Python Version by Aaron MacDonald as our basis, because it's easier to read than the C++ version. I won't include the full thing here like I did with PCG code because there's a whole lot more of it. Just check out the wiki page on roguebasin if you want every little detail.

Note that there are lots of types of FOV, and no clear winner in every category you're likely to care about. The basic trade off is that more accuracy gives less speed. That's kinda how it always goes in computing. Precise Permissive FOV is a slightly earlier version of the "PERMISSIVE" style that the comparison page talks about where the permissive level is always set to 8 instead of being adjustable.

PPFOV: The Theory

Again, the page that fully explains in detail how the technique works is here, but here's the basic idea:

  • This is a quadrant based technique. We only write how to perform one quadrant worth of checking, and then we perform mirroring to make it work in the other three quadrants.
  • This only uses integral math. As a result, we won't have any possibility of view errors coming from floating point rounding trouble.
  • We start with one "view", which is an arc across the entire quadrant. We sweep out from the center of the FOV testing squares one at a time.
  • When a square blocks vision, we have to update our current list of views based on how it blocks the view. A blocking tile might make a view narrower, split it into two views, or block a view entirely (which kills the view).
  • When we're out of live views, or we reach the edge of the range we were given, we stop.

PPFOV: The Python

We're not going to have every bit of the python code here, but let's start by looking at the top level signatures the python code has.

def fieldOfView(startX, startY, mapWidth, mapHeight, radius, \
  funcVisitTile, funcTileBlocked):

class __Line(object):

class __ViewBump:

class __View:

def __checkQuadrant(visited, startX, startY, dx, dy, \
  extentX, extentY, funcVisitTile, funcTileBlocked):

def __visitCoord(visited, startX, startY, x, y, dx, dy, viewIndex, \
  activeViews, funcVisitTile, funcTileBlocked):

def __addShallowBump(x, y, activeViews, viewIndex):

def __addSteepBump(x, y, activeViews, viewIndex):

def __checkView(activeViews, viewIndex):

So, it looks like in python you pass a function of what to do at each location. Well, we don't want to do arbitrary mutations, so we'll just return the set of locations found and let the caller decide what to do. So we'll accept a starting point, and a map edge (I guess we assume that the map has 0 as the lower bounds), a radius, and then a function to determine if a tile is blocked at a location.

PPFOV: The Haskell

(Note: As with the PCG stuff, I'm cheating a little bit here and using an old version of PPFOV that I converted from Python to Haskell a year ago to help me though some of the mental jumps here).

Well, assuming that tiles run from 0 through some max seems lame to me when we're already accepting a func that says if a tile blocks or not. We'll just force that function to accept any possible location at all, and then that simplifies how you call the FOV quite a bit. Let's try some signatures out.

module PPFOV (ppfov, VisionBlocked) where 

type VisionBlocked = (Int,Int) -> Bool

fov :: (Int,Int) -> Int -> VisionBlocked -> Set (Int,Int)

data SightLine = SightLine Int Int Int Int
    deriving (Eq, Show)

data ViewBump = ViewBump Int Int (Maybe ViewBump)
    deriving (Eq, Show)

data View = View SightLine SightLine (Maybe ViewBump) (Maybe ViewBump)
    deriving (Eq, Show)

checkQuadrant :: Set (Int,Int) -> (Int,Int) -> (Int,Int) -> (Int,Int) -> VisionBlocked -> Set (Int,Int) -> Set (Int,Int)

addShallowBump :: (Int,Int) -> Int -> [View] -> [View]

addSteepBump :: (Int,Int) -> Int -> [View] -> [View]

checkView :: Int -> [View] -> [View]

Oof! Three (Int,Int) in a row? That's not the best, you could mix up the ordering on those tuples very easily. The results would be obviously not what we want, but we wouldn't necessarily have any clear reason why. It'd be much better to use some alternate types like passing around a Quadrant value or something, so that it's not a big list of identically typed values you're passing in, and if you get the order wrong you get a compile error instead of silent breakage. However, since it's private to our own module, we can probably stomach the situation. If it was a function that we exposed to the world we'd be at a much bigger obligation to make it an easier to use signature.

Well, something that I skipped over is that we've got a bunch of sub-functions for the SightLine type that we have to write out too. They've got some slightly confusing names, so instead of saying pBelow for pointBelow, we'll say belowPoint. Then if we use it infix it reads like an actual sentence

relativeSlope :: SightLine -> (Int,Int) -> Int
relativeSlope (SightLine xi yi xf yf) (x,y) = let
    dx = xf - xi
    dy = yf - yi
    in (dy * (xf - x)) - (dx * (yf - y))

collinearLine :: SightLine -> SightLine -> Bool
collinearLine self (SightLine xi yi xf yf) =
    collinearPoint self (xi,yi) && collinearPoint self (xf,yf)

collinearPoint :: SightLine -> (Int,Int) -> Bool
collinearPoint self p = relativeSlope self p == 0

aboveOrCollinearPoint :: SightLine -> (Int,Int) -> Bool
aboveOrCollinearPoint self p = relativeSlope self p <= 0

abovePoint :: SightLine -> (Int,Int) -> Bool
abovePoint self p = relativeSlope self p < 0

belowOrCollinearPoint :: SightLine -> (Int,Int) -> Bool
belowOrCollinearPoint self p = relativeSlope self p >= 0

belowPoint :: SightLine -> (Int,Int) -> Bool
belowPoint self p = relativeSlope self p > 0

And a ghci check of course,

RL-tut> s = SightLine 0 0 1 1
RL-tut> s `belowPoint` (0,70)
True
RL-tut> s `collinearLine` (SightLine 5 5 6 6)
True
RL-tut> s `aboveOrCollinearPoint` (4,4)
True
RL-tut> s `aboveOrCollinearPoint` (4,3)
True
RL-tut> s `aboveOrCollinearPoint` (4,5)
False

Seems good. We'll keep building up from the smallest stuff to the bigger stuff, going good so far.

-- | Deletes the View at the index specified from the list if the two lines of
-- the View are collinear and the lines pass through either extremity of the FOV
-- origin, (0,1) or (1,0).
--
-- checkView 0 [View (SightLine 0 0 1 1) (SightLine 2 2 3 3) Nothing Nothing] == id
-- 
-- checkView 0 [View (SightLine 0 1 1 2) (SightLine 2 3 3 4) Nothing Nothing] == []
checkView :: Int -> [View] -> [View]
checkView index views = let
    theView = views !! index
    (View shallowLine _ _ _) = theView
    (View _ steepLine _ _) = theView
    delete i list = take i list ++ drop (i+1) list
    in if (shallowLine `collinearLine` steepLine) &&
            (shallowLine `collinearPoint` (0,1) || shallowLine `collinearPoint` (1,0))
        then delete index views
        else views

Not... looking... so good. Seems like View should use record syntax. Let's persist just a little more, this might be just a weird situation.

addSteepBump :: (Int,Int) -> Int -> [View] -> [View]
addSteepBump (x,y) index views = let
    theView = views !! index
    (View shallowLineO steepLineO maybeShallowBumpO maybeSteepBumpO) = theView
    (SightLine xiShO yiShO xfShO yfShO) = shallowLine
    newSteepXF = x
    newSteepYF = y
    newSteepBump = ViewBump x y maybeSteepBumpO
    steepBelow bump xc yc = case bump of
        Just (ViewBump cbx cby nextBump) -> if .....???
        Nothing -> (xc,yc)
    (newSteepXI,newSteepYI) = steepBelow
    in ...

That's seriously as far as I got trying to do the direct translation. This is not gonna work. You can't always just convert code into Haskell line by line, and this is a great example of when you can't. We need a better approach to things.

Take 2: Representation Matters

Let's start with a better View representation and a helper to make building View values easy while we're at ait. In this case, part of the problem is that the list of bumps going back is being a clumsy linked list, but Haskell already has a linked list you're supposed to use: the normal list type. That way we can use all our normal list functions that we know and love.

-- | A view within the FOV computation.
data View = View {
    getShallowBumps :: [(Int,Int)],
    getShallowLine :: SightLine,
    getSteepBumps :: [(Int,Int)],
    getSteepLine :: SightLine
    } deriving (Eq, Show)

-- | Makes a new view using the shallow and steep line given, and no bumps.
mkView :: SightLine -> SightLine -> View
mkView shallowLine steepLine = View {
    getShallowBumps=[],
    getShallowLine=shallowLine,
    getSteepBumps=[],
    getSteepLine=steepLine}

Great, so what's checkView look like now?

checkView :: Int -> [View] -> [View]
checkView viewIndex activeViews = let
    shallowIsSteep = theShallowLine `collinearLine` theSteepLine
    lineOnExtremity = (theShallowLine `collinearPoint` (0,1) || theShallowLine `collinearPoint` (1,0))
    theView = activeViews !! viewIndex
    theShallowLine = shallowLine theView
    theSteepLine = steepLine theView
    delete i list = take i list ++ drop (i+1) list
    in if shallowIsSteep && lineOnExtremity
        then delete viewIndex activeViews
        else activeViews

Do our previous checks still work? Allowing for a difference in how you declare View values,

RL-tut> checkView 0 [mkView (SightLine 0 0 1 1) (SightLine 2 2 3 3)]
[View {getShallowBumps = [], getShallowLine = SightLine 0 0 1 1, getSteepBumps = [], getSteepLine = SightLine 2 2 3 3}]
RL-tut> checkView 0 [mkView (SightLine 0 1 1 2) (SightLine 2 3 3 4)]
[]

Folding

Now let's try to add a steep bump just to a View. We can adjust a [View] into a new [View] once we'd hammered out how to change just one view. Careful readers will note that to add a steep bump we just need to replace the steep bump and steep line values. We read from the shallow bump as part of the loop, but we don't change it.

Adding the new steep bump into the front of the list of steep bumps is super easy. To add an item to the front of the list we use the (:) constructor (called "cons", thanks to Haskell's ancient alliance with the Lisp clan). I know that (:) looks like an operator, but symbol operators that start with a : character, including just (:) on its own, are constructors. It's a little arbitrary, but since there's no way to write something like + "in uppercase" you need some other way to distinguish symbol functions from symbol constructors, so that's how you do it.

addSteepBump :: (Int,Int) -> View -> View
addSteepBump loc view = let
    newSteepBumps = loc : (steepBumps view)
    newSteepLine =

Now comes the hard part. We have to think about what the loop here is trying to accomplish. This is a kind of thing that Haskell calls a "fold". A fold is when you take a collection of values and a binary operator, and you collapse the collection down into an accumulation value one at a time using the binary operator, and you get just one value at the end. There are left folds and right folds and the fold can be strict or not, but they all take a complex structure and combine it down into one value. A catamorphism it's called. The super common folds are things that you've already heard of:

  • sum collapses a group of numbers using the (+) operator.
  • product is the same but with (*).
  • any combines Bool values using (||)
  • all is the same but for (&&)
  • max and min "combine" a group of numbers using (>) and (<), which is close to what we'll be doing with the SightLine here.

As I said there are many flavors of folding, and learning all of them and how they're slightly different is an interesting way to spend 20 minutes. Sometimes the fold you want is already in a library, and sometimes you have to build one yourself using one of the base fold operations, foldr, foldl, and foldl'. In our case, this is a kinda weird loop and we'll be building our own fold operation and running it with one of the base folding functions. Which one? We'll pick foldl': it gives you the effect of doing a loop over a finite list and evaluating the accumulator at each step. The ' bit is because it's strict (usually in Haskell the version of a function with a ' at the end is more strict than the version without). The Haskell Wiki has a page about folds if you want to read more about the basic fold patterns. So, how do we use foldl'?

foldl' :: Foldable t => (b -> a -> b) -> b -> t a -> b

Lists are Foldable, so we're gonna be fine there. We need some sort of folding operation, a starting value, a list of inputs, and we get a value back at the end. Our base value is a SightLine with the new bump's x and y in the "final" position and the current steep line's initial values as our own "initial" values. Our list to fold over when adding a shallow bump is the steepBumps list. Have a look and hopefully it will make sense:

addSteepBump :: (Int,Int) -> View -> View
addSteepBump loc@(x,y) view = let
    (SightLine xi yi xf yf) = steepLine view
    newSteepBumps = loc : (steepBumps view)
    newSteepLine = foldl' (\line@(SightLine _ _ xfL yfL) p@(x,y) -> if line `belowPoint` p
            then (SightLine x y xfL yfL)
            else line
        ) (SightLine xi yi x y) (shallowBumps view)
    in view { steepBumps = newSteepBumps, steepLine = newSteepLine }

Well, that doesn't seem clear at all. Let's write it more clearly.

-- | Computes the next line when adding a shallow or steep bump. If the
-- predicate passes then you get a line with its final coordinates replaced with
-- the point given. Otherwise you get the same SightLine back.
foldLineUsing :: (SightLine -> (Int,Int) -> Bool) -> SightLine -> (Int,Int) -> SightLine
foldLineUsing pred line@(SightLine _ _ xf yf) loc@(x,y) = if line `pred` loc
    then SightLine x y xf yf
    else line

-- | Adds a steep bump to a View, updating its steep line as necessary.
addSteepBump :: (Int,Int) -> View -> View
addSteepBump loc@(x,y) view = let
    (SightLine xi yi _ _) = steepLine view
    newSteepBumps = loc : (steepBumps view)
    newSteepLine = foldl' (foldLineUsing belowPoint) (SightLine xi yi x y) (shallowBumps view)
    in view { steepBumps = newSteepBumps, steepLine = newSteepLine }

That feels a lot better. We're folding along the shallowBumps by determining the next line depending on if the line is below the point or not. If so, we update the initial coordinates of the line. Hey, look! That's what the Python code did. We're on the right track. Adding a shallow bump is nearly identical.

-- | Adds a shallow bump to a View, updating its shallow line as necessary.
addShallowBump :: (Int,Int) -> View -> View
addShallowBump loc@(x,y) view = let
    (SightLine xi yi _ _) = shallowLine view
    newShallowBumps = loc : (shallowBumps view)
    newShallowLine = foldl' (foldLineUsing abovePoint) (SightLine xi yi x y) (steepBumps view)
    in view { shallowBumps = newShallowBumps, shallowLine = nhallowLine }

Pretty much the exact inverse as before. Seems good to go.

Visiting a Coordinate

Since we started at the bottom of the list and we're working our way up the list this bring us to the all important visitCoord function.

This will be ugly.

You have been warned.

-- | Adds a single Location to the set of visited locations if it's within
--   one of the views, and updates any views as necessary.
visitCoord :: (Int,Int) -> (Int,Int) -> (Int,Int) -> [View] -> VisionBlocked -> Set (Int,Int) -> (Set (Int,Int), [View])
visitCoord (sx,sy) (dx,dy) (qx,qy) activeViews vision visited =
    let topLeft = (dx,dy+1)
        bottomRight = (dx+1,dy)
        realX = dx * qx
        realY = dy * qy
        trueLocation = (sx + realX, sy + realY)
        viewIndex = calcViewIndex activeViews bottomRight
        in if viewIndex == (length activeViews) || (shallowLine (activeViews !! viewIndex)) `aboveOrCollinearPoint` topLeft
            then (visited, activeViews) -- No compatible views. Return without altering visited or activeViews
            else let newVisited = S.insert trueLocation visited
                     visionBlocked = vision trueLocation
                     in if visionBlocked
                        then let currentView = activeViews !! viewIndex -- Vision is blocked, calculate how it affects the view.
                                 shallowAboveBottomRight = (shallowLine currentView) `abovePoint` bottomRight
                                 steepBelowTopLeft = (steepLine currentView) `belowPoint` topLeft
                                 in case (shallowAboveBottomRight, steepBelowTopLeft) of
                                    (True, True) -> (newVisited, remove viewIndex activeViews)
                                    (True, False) -> (newVisited, bumpAndCheck addShallowBump activeViews viewIndex topLeft)
                                    (False, True) -> (newVisited, bumpAndCheck addSteepBump activeViews viewIndex bottomRight)
                                    (False, False) -> let clonedViews = add viewIndex activeViews currentView in
                                                      let shallowChecked = bumpAndCheck addShallowBump clonedViews (viewIndex+1) topLeft in
                                                      let steepChecked = bumpAndCheck addSteepBump shallowChecked viewIndex bottomRight
                                                          in (newVisited, steepChecked)
                        else (newVisited,activeViews) -- Vision not blocked, we don't adjust any views.

calcViewIndex :: [View] -> (Int,Int) -> Int
calcViewIndex activeViews bottomRight = let
    go tmp views bottomRight = if tmp < (length views) &&
            (steepLine (views!!tmp)) `belowOrCollinearPoint` bottomRight
        then go (tmp+1) views bottomRight
        else tmp
    in go 0 activeViews bottomRight

bumpAndCheck :: ((Int,Int) -> View -> View) -> [View] -> Int -> (Int,Int) -> [View]
bumpAndCheck bumpf activeViews viewIndex bump = out
    where view = activeViews !! viewIndex
          bumpedView = bumpf bump view
          out = if validView bumpedView
            then update viewIndex activeViews bumpedView
            else remove viewIndex activeViews

validView :: View -> Bool
validView view = not (shallowIsSteep && lineOnExtremity)
    where shallowIsSteep = shallowLine' `collinearLine` steepLine'
          lineOnExtremity = (shallowLine' `collinearPoint` (0,1) || shallowLine' `collinearPoint` (1,0))
          shallowLine' = shallowLine view
          steepLine' = steepLine view

add :: (Integral a) => a -> [b] -> b -> [b]
add a (b:bs) new
    | a <  0 = b : bs
    | a == 0 = new : b : bs
    | otherwise = b : add (a-1) bs new
add 0 []     new = [new]
add _ []     _ = []

update :: (Integral a) => a -> [b] -> b -> [b]
update a (b:bs) new
    | a <  0 = b : bs
    | a == 0 = new : bs
    | otherwise = b : update (a-1) bs new
update _ []     _ = []

remove :: (Integral a) => a -> [b] -> [b]
remove a (b:bs)
    | a <  0 = b : bs
    | a == 0 = bs
    | otherwise = b : remove (a-1) bs
remove _ [] = []

It's the worst, but I dare not change a thing until we can turn it on and see if it works each time we make a change. So, we must endure. In enduring, grow strong.

What happened here is that at each location we visit we have to do some conversions (to account for the mirroring across quadrants) and check what view the location is within (if any) and then if it is in a view we have to check if vision is blocked and update our view if so. The wiki page explains the logic behind it better than I can.

One final, ugly, sprint before we can fiddle with things.

-- | Performs view calculations on a single Quadrant relative to the start
--   position of the overall FOV computation.
checkQuadrant :: VisionBlocked -> Int -> (Int,Int) -> (Int,Int) -> Set (Int,Int)
checkQuadrant vision range (sx,sy) (qx,qy) = checkSub coordsToCheck (S.singleton (sx,sy)) startViewList
    where shallowLineStart = SightLine 0 1 range 0
          steepLineStart   = SightLine 1 0 0     range
          startViewList = [mkView shallowLineStart steepLineStart]
          coordsToCheck = coordsFromRange range
          {- During each subfunction pass, if there are no more active views we halt
          and return the set of visited coordinates so far. Otherwise we use visitCoord
          to compute the next pass, giving us a new set of visited coordinates and
          a new list of active views. -}
          checkSub :: [(Int,Int)] -> Set (Int,Int) -> [View] -> Set (Int,Int)
          checkSub _      visited []          = visited
          checkSub []     visited _           = visited
          checkSub ((dx,dy):cs) visited activeViews = checkSub cs newVisited newActiveViews
            where (newVisited,newActiveViews) = visitCoord (sx,sy) (dx,dy) (qx,qy) activeViews vision visited

-- | Turns a range for the vision into a list of the locations, relative
--   to the start position, that should be checked per quadrant.
coordsFromRange :: Int -> [(Int,Int)]
coordsFromRange range = do
    let maxIndex = (2*range) + 1
    i <- [1..(maxIndex-1)]
    let startJ = max (i-range) 0
    let maxJ = (min i range) + 1
    j <- [startJ..(maxJ-1)]
    pure (i-j,j)

-- | Maps the function given over the list given, and return a set
--   that is the union of all the result sets.
unionMap :: (Ord b) => (a -> Set b) -> [a] -> Set b
unionMap f list = S.unions $ map f list

fov :: VisionBlocked -> Int -> (Int,Int) -> Set (Int,Int)
fov vision range start = unionMap (checkQuadrant vision range start)
    [(1,1),((-1),1),(1,(-1)),((-1),(-1))]

So for each quadrant, we sweep outward checking locations and updating views and so forth, and then at the end we dump it all into a single Set (Int,Int) and hand it back to the caller.

Finally we can update Main.hs a bit and see if it gives good results. commit

screenshot of fov working

Cleaning Up A Bit Of The Tire Fire

First of all we can make calcViewIndex better by making it drop items from the list as it goes and stop passing the same arg over and over each cycle of the loop. Incidentally it'll be ever so slightly faster because the lookup each loop doesn't have to iterate over the past views that we already skipped. That's pretty minor though since the number of views is usually never more than a handful anyway.

-- | This is a freakish version of 'dropWhile' that keeps track of how many times
-- it dropped an item from the list and gives that as the final value.
calcViewIndex :: [View] -> (Int,Int) -> Int
calcViewIndex activeViews bottomRight = let
    go tmp [] = tmp
    go tmp (v:vs) = if (steepLine v) `belowOrCollinearPoint` bottomRight
        then go (tmp+1) vs
        else tmp
    in go 0 activeViews

We can make bumpAndCheck a little more ideomatic

-- | Applies a bump operation, checks, and returns the new view list as a single operation.
bumpAndCheck :: ((Int,Int) -> View -> View) -> [View] -> Int -> (Int,Int) -> [View]
bumpAndCheck bumpf activeViews viewIndex bump = let
    view = activeViews !! viewIndex
    bumpedView = bumpf bump view
    in take viewIndex activeViews ++ if validView bumpedView
        then bumpedView : drop (viewIndex+1) activeViews
        else drop (viewIndex+1) activeViews

This lets us cut one use of remove, and also our one use of update. We're still using delete within checkView and also remove within visitCoord. We don't actaully use checkView anywhere, so we'll cut that. Then we can define remove as just an inner fuction of visitCoord. We can redefine it in terms of take and drop so that it's somewhat easier to see the intent of what's going on.

remove i list = take i list ++ drop (i+1) list

There's other minor cleanups we can do, like converting some of my old where based code to use let instead just so that the style matches. Nothing huge.

More?

Our FOV code runs and returns correct results.

It probably doesn't run as fast as possible. However, without writing some benchmarking code that's impossible to say. Benchmarks are beyond the scope of this week's lesson though. All I can say at the moment is that if you hold a direction the character runs down a hall seemingly as fast as the keyboard generates KeyState'Repeating events. So that's good enough.

When speed becomes a real problem, we'll go over it. If never turns out to be a problem I'll still go over it as a bonus lesson once the main content of this series is done.

commit

results matching ""

    No results matching ""