├── go.mod ├── images ├── explored_shadowcast.gif └── visible_shadowcast.gif ├── .gitignore ├── LICENSE ├── fov └── fov.go └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/norendren/go-fov 2 | 3 | go 1.25 4 | -------------------------------------------------------------------------------- /images/explored_shadowcast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/norendren/go-fov/HEAD/images/explored_shadowcast.gif -------------------------------------------------------------------------------- /images/visible_shadowcast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/norendren/go-fov/HEAD/images/visible_shadowcast.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .idea/ 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 norendren 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fov/fov.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package fov implements basic recursive shadowcasting for displaying a field of view on a 2D Grid 3 | The exact structure of the grid has been abstracted through an interface that merely provides 3 methods 4 | expected of any grid-based implementation 5 | */ 6 | package fov 7 | 8 | import ( 9 | "math" 10 | ) 11 | 12 | // GridMap is meant to represent the basic functionality that is required to detect the opaqueness 13 | // and boundaries of a 2D grid 14 | type GridMap interface { 15 | InBounds(x, y int) bool 16 | IsOpaque(x, y int) bool 17 | } 18 | 19 | //point to hold a x, y position 20 | type point struct { 21 | x, y int 22 | } 23 | 24 | // gridSet is an efficient and idiomatic way to implement sets in go, as an empty struct takes up no space 25 | // and nothing more than a set of keys is needed to store the range of visible cells 26 | type gridSet map[point]struct{} 27 | 28 | // View is the item which stores the visible set of cells any time it is called. This should be called any time 29 | // a player's position is updated 30 | type View struct { 31 | Visible gridSet 32 | } 33 | 34 | // New returns a new instance of an fov calculator 35 | func New() *View { 36 | return &View{} 37 | } 38 | 39 | // Compute takes a GridMap implementation along with the x and y coordinates representing a player's current 40 | // position and will internally update the visibile set of tiles within the provided radius `r` 41 | func (v *View) Compute(grid GridMap, px, py, radius int) { 42 | v.Visible = make(map[point]struct{}) 43 | v.Visible[point{px, py}] = struct{}{} 44 | for i := 1; i <= 8; i++ { 45 | v.fov(grid, px, py, 1, 0, 1, i, radius) 46 | } 47 | } 48 | 49 | // fov does the actual work of detecting the visible tiles based on the recursive shadowcasting algorithm 50 | // annotations provided inline below for (hopefully) easier learning 51 | func (v *View) fov(grid GridMap, px, py, dist int, lowSlope, highSlope float64, oct, rad int) { 52 | // If the current distance is greater than the radius provided, then this is the end of the iteration 53 | if dist > rad { 54 | return 55 | } 56 | 57 | // Convert our slope into integers that will represent the "height" from the player position 58 | // "height" will alternately apply to x OR y coordinates as we move around the octants 59 | low := math.Floor(lowSlope*float64(dist) + 0.5) 60 | high := math.Floor(highSlope*float64(dist) + 0.5) 61 | 62 | // inGap refers to whether we are currently scanning non-blocked tiles consecutively 63 | // inGap = true means that the previous tile examined was empty 64 | inGap := false 65 | 66 | for height := low; height <= high; height++ { 67 | // Given the player coords and a distance, height and octant, determine which tile is being visited 68 | mapx, mapy := distHeightXY(px, py, dist, int(height), oct) 69 | if grid.InBounds(mapx, mapy) && distTo(px, py, mapx, mapy) < rad { 70 | // As long as a tile is within the bounds of the map, if we visit it at all, it is considered visible 71 | // That's the efficiency of shadowcasting, you just dont visit tiles that aren't visible 72 | v.Visible[point{mapx, mapy}] = struct{}{} 73 | } 74 | 75 | if grid.InBounds(mapx, mapy) && grid.IsOpaque(mapx, mapy) { 76 | if inGap { 77 | // An opaque tile was discovered, so begin a recursive call 78 | v.fov(grid, px, py, dist+1, lowSlope, (height-0.5)/float64(dist), oct, rad) 79 | } 80 | // Any time a recursive call is made, adjust the minimum slope for all future calls within this octant 81 | lowSlope = (height + 0.5) / float64(dist) 82 | inGap = false 83 | } else { 84 | inGap = true 85 | // We've reached the end of the scan and, since the last tile in the scan was empty, begin 86 | // another on the next depth up 87 | if height == high { 88 | v.fov(grid, px, py, dist+1, lowSlope, highSlope, oct, rad) 89 | } 90 | } 91 | } 92 | } 93 | 94 | // IsVisible takes in a set of x,y coordinates and will consult the visible set (as a gridSet) to determine 95 | // whether that tile is visible. 96 | func (v *View) IsVisible(x, y int) bool { 97 | if _, ok := v.Visible[point{x, y}]; ok { 98 | return true 99 | } 100 | return false 101 | } 102 | 103 | // distHeightXY performs some bitwise and operations to handle the transposition of the depth and height values 104 | // since the concept of "depth" and "height" is relative to whichever octant is currently being scanned 105 | func distHeightXY(px, py, d, h, oct int) (int, int) { 106 | if oct&0x1 > 0 { 107 | d = -d 108 | } 109 | if oct&0x2 > 0 { 110 | h = -h 111 | } 112 | if oct&0x4 > 0 { 113 | return px + h, py + d 114 | } 115 | return px + d, py + h 116 | } 117 | 118 | // distTo is simply a helper function to determine the distance between two points, for checking visibility of a tile 119 | // within a provided radius 120 | func distTo(x1, y1, x2, y2 int) int { 121 | vx := math.Pow(float64(x1-x2), 2) 122 | vy := math.Pow(float64(y1-y2), 2) 123 | return int(math.Sqrt(vx + vy)) 124 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-fov 2 | 3 | go-fov is meant to provide an easy-to-implement and (relatively) unobstrusive way to quickly add field of view to a 4 | top-down, grid-based game (it was made with traditional roguelikes in mind). 5 | 6 | go-fov uses recursive shadowcasting to track the cells which are considered "visible." For a much better explanation on 7 | the algorithm than I could ever provide, please see this [roguebasin article](http://www.roguebasin.com/index.php?title=FOV_using_recursive_shadowcasting) 8 | or an absolutely wonderful technical explanation from [Gridbugs](https://gridbugs.org/visible-area-detection-recursive-shadowcast/) 9 | 10 | * [Installation](#installation) 11 | * [Usage](#usage) 12 | * [Quickstart](#quickstart) 13 | * [Requirements](#requirements) 14 | * [Example Implementation](#example-implementation) 15 | * [Initialization](#initialization) 16 | * [The "Update" Section](#the-update-section) 17 | * [The "Draw" Section](#the-draw-section) 18 | * [Pictures](#pictures) 19 | 20 | ## Installation 21 | To use go-fov: 22 | `import "github.com/norendren/go-fov/fov"` and `go get` to acquire the dependency. 23 | 24 | Or to retrieve it independently: 25 | ``` 26 | go get github.com/norendren/go-fov/fov 27 | ``` 28 | 29 | ## Usage 30 | go-fov is intended to be unassuming, while still providing the efficiency that recursive shadowcasting brings to FOV calculations 31 | 32 | ### Quickstart 33 | If you can already tell go-fov trying to provide, you may not need a long-winded explanation, and the following 34 | three steps will be enough to get you started: 35 | 1. Instantiate a `View` with `myFOVCalculator := fov.New()` and store it alongside your map and player data 36 | 2. Each time the player moves, call `myFOVCalculator.Compute(yourMap, playerXCoord, playerYCoord, radius)` 37 | 3. When drawing your map, check if the map coordinate is visible by calling `myFOVCalculator.IsVisible(x,y)` 38 | 39 | And that's it! 40 | 41 | --- 42 | 43 | ### Requirements 44 | Three things are required: 45 | * An instance of the View struct, which represents the current "view" of the player at the time `Compute()` is called 46 | * Your map must implement the `GridMap` interface, which only has two methods `InBounds` and `IsOpaque` used to determine 47 | whether a given `x,y` coordinate is within the boundaries of your map and whether it is opaque and therefore blocks vision 48 | * Each time position is changed, a call to `Compute()` must be made in order to update the set of visible cells 49 | 50 | ### Example Implementation 51 | A sample implementation abstracted from a game written using the [Ebiten 2D game library](https://github.com/hajimehoshi/ebiten) 52 | 53 | #### Initialization 54 | 55 | Here we have an instance of a `Game` that is initialized to contain an FOV Calculator using the `fov.New()` helper 56 | function. This returns an instance of a `View` struct which we will later use to compute the field of view any time 57 | the player moves 58 | ```go 59 | // Given a game struct which has an instance of an fov View 60 | type Game struct { 61 | Pressed []ebiten.Key 62 | Level *dungeonGen.Floor 63 | FOVCalc *fov.View 64 | } 65 | 66 | func main() { 67 | g := &Game{ 68 | Level: dungeonGen.New(rows, cols, int(fontSize)), 69 | FOVCalc: fov.New(), 70 | } 71 | 72 | p.StartingPosition(g.Level) 73 | 74 | ebiten.SetWindowSize(width, height) 75 | ebiten.SetWindowTitle("Aldwater") 76 | if err := ebiten.RunGame(g); err != nil { 77 | log.Fatal(err) 78 | } 79 | 80 | } 81 | ``` 82 | 83 | #### The "Update" section 84 | In the game's `Update` section (literally called `Update` in Ebiten and many game engines, but wherever your 85 | computations are done), merely ensure that a call to `Compute` is made each time a player's position changes 86 | ```go 87 | func (g *Game) Update(screen *ebiten.Image) error { 88 | p.HandleMovement(g.Level) 89 | g.FOVCalc.Compute(g.Level, p.X, p.Y, 6) 90 | 91 | return nil 92 | } 93 | ``` 94 | Here we can assume `HandleMovement` manages the actual updates to a player's coordinates on the game map and, once 95 | those are updated, the call to `Compute` will determine which cells are visible from the player's new position. 96 | 97 | Let's quickly review the `Compute` method and it's parameters: 98 | ```go 99 | func (v *View) Compute(grid GridMap, px, py, radius int) 100 | ``` 101 | * `grid` is an implementation of the GridMap interface described above. 102 | * `px,py` are the current x and y coordinates of the player 103 | * `radius` is the radius of the player's sight range. A higher number here equates to the ability to see farther 104 | 105 | From there the code has been annotated in such a way that the truly curious can refer once again to sources that describe 106 | recursive shadowcasting much better than myself (see links above) 107 | 108 | #### The "Draw" section 109 | Now that we are computing the set of visible cells each time the player's position changes, we need to draw those 110 | cells onto the screen. 111 | ```go 112 | func (g *Game) Draw(screen *ebiten.Image) { 113 | for y, row := range g.Level.Area { 114 | for x, tile := range row { 115 | if g.FOVCalc.IsVisible(x, y) { 116 | text.Draw(screen, tile.Char, normalFont, tile.Posx, tile.Posy, tile.Color) 117 | } 118 | } 119 | } 120 | 121 | // Draw the player character 122 | text.Draw(screen, 123 | p.Char, 124 | normalFont, 125 | g.Level.Area[p.Y][p.X].Posx, 126 | g.Level.Area[p.Y][p.X].Posy, 127 | color.White) 128 | 129 | } 130 | ``` 131 | The nested `for` loops are going to iterate over every single cell in the game area. For each cell encountered 132 | a call is made to `IsVisible(x,y)` with `x,y` coordinates. Internally `go-fov` stores a set of visible cells each time 133 | `Compute()` is called. Then, when a call to `IsVisible()` occurs, a simple check is made against that set of stored cells 134 | 135 | And that's it! Optionally, if one would like to include the concept of "Explored" tiles, that becomes quite straightforward 136 | as well. "Explored" here means that they player *has* seen the tile before, but it is not currently visible. Here's how 137 | I implemented that myself while creating this example. 138 | 139 | ```go 140 | func (g *Game) Draw(screen *ebiten.Image) { 141 | for y, row := range g.Level.Area { 142 | for x, tile := range row { 143 | if g.FOVCalc.IsVisible(x, y) { 144 | text.Draw(screen, tile.Char, normalFont, tile.Posx, tile.Posy, tile.Color) 145 | tile.Explored = true 146 | } 147 | if tile.Explored && !g.FOVCalc.IsVisible(x, y) { 148 | text.Draw(screen, tile.Char, normalFont, tile.Posx, tile.Posy, displayResource.Color3) 149 | } 150 | } 151 | } 152 | 153 | text.Draw(screen, 154 | p.Char, 155 | normalFont, 156 | g.Level.Area[p.Y][p.X].Posx, 157 | g.Level.Area[p.Y][p.X].Posy, 158 | color.White) 159 | } 160 | ``` 161 | Notice that, within the `IsVisible()` check, the current tile we're examining has a boolean attribute called `Explored` 162 | that gets set to true. Then, immediately after we draw all the visible cells to the screen, a secondary check for cells 163 | which are `Explored` *but not* `Visible` is made, and those are drawn in a different color. 164 | 165 | --- 166 | 167 | ## Pictures 168 | Visible only, no "Explored" tiles (the first code snippet) 169 | 170 | ![Visible-only FOV!](images/visible_shadowcast.gif) 171 | 172 | 2nd Implementation with "Explored" tiles being tracked 173 | 174 | ![Explored FOV](images/explored_shadowcast.gif) 175 | --------------------------------------------------------------------------------