├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── fixtures ├── 300x300.png ├── 9x9.png └── logo.png ├── go.mod ├── go.sum ├── grid.go ├── grid_test.go ├── path.go ├── path_test.go ├── point.go ├── point_test.go ├── store.go ├── store_test.go ├── view.go └── view_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kelindar] 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 5 | GO111MODULE: "on" 6 | jobs: 7 | test: 8 | name: Test with Coverage 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up Go 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: 1.23 15 | - name: Check out code 16 | uses: actions/checkout@v2 17 | - name: Install dependencies 18 | run: | 19 | go mod download 20 | - name: Run Unit Tests 21 | run: | 22 | go test -race -covermode atomic -coverprofile=profile.cov ./... 23 | - name: Upload Coverage 24 | uses: shogo82148/actions-goveralls@v1 25 | with: 26 | path-to-profile: profile.cov 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelindar/tile/754fe1b62b50b0b8cb8bef04743de4b9f0644d24/.gitignore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Roman Atachiants 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tile: Data-Oriented 2D Grid Engine 2 | 3 |

4 | 5 |
6 | Go Version 7 | PkgGoDev 8 | Go Report Card 9 | License 10 | Coverage 11 |

12 | 13 | This repository contains a 2D tile map engine which is built with data and cache friendly ways. My main goal here is to provide a simple, high performance library to handle large scale tile maps in games. 14 | 15 | - **Compact**. Each tile value is 4 bytes long and each grid page is 64-bytes long, which means a grid of 3000x3000 should take around 64MB of memory. 16 | - **Thread-safe**. The grid is thread-safe and can be updated through provided update function. This allows multiple goroutines to read/write to the grid concurrently without any contentions. There is a spinlock per tile page protecting tile access. 17 | - **Views & observers**. When a tile on the grid is updated, viewers of the tile will be notified of the update and can react to the changes. The idea is to allow you to build more complex, reactive systems on top of the grid. 18 | - **Zero-allocation** (or close to it) traversal of the grid. The grid is pre-allocated entirely and this library provides a few ways of traversing it. 19 | - **Path-finding**. The library provides a A\* pathfinding algorithm in order to compute a path between two points, as well as a BFS-based position scanning which searches the map around a point. 20 | 21 | _Disclaimer_: the API or the library is not final and likely to change. Also, since this is just a side project of mine, don't expect this to be updated very often but please contribute! 22 | 23 | # Grid & Tiles 24 | 25 | The main entry in this library is `Grid` which represents, as the name implies a 2 dimentional grid which is the container of `Tile` structs. The `Tile` is essentially a cursor to a particular x,y coordinate and contains the following 26 | 27 | - Value `uint32` of the tile, that can be used for calculating navigation or quickly retrieve sprite index. 28 | - State set of `T comparable` that can be used to add additional information such as objects present on the tile. These things cannot be used for pathfinding, but can be used as an index. 29 | 30 | Granted, uint32 value a bit small. The reason for this is the data layout, which is organised in thread-safe pages of 3x3 tiles, with the total size of 64 bytes which should neatly fit onto a cache line of a CPU. 31 | 32 | In order to create a new `Grid[T]`, you first need to call `NewGridOf[T]()` method which pre-allocates the required space and initializes the tile grid itself. For example, you can create a 1000x1000 grid as shown below. The type argument `T` sets the type of the state objects. In the example below we want to create a new grid with a set of strings. 33 | 34 | ```go 35 | grid := tile.NewGridOf[string](1000, 1000) 36 | ``` 37 | 38 | The `Each()` method of the grid allows you to iterate through all of the tiles in the grid. It takes an iterator function which is then invoked on every tile. 39 | 40 | ```go 41 | grid.Each(func(p Point, t tile.Tile[string]) { 42 | // ... 43 | }) 44 | ``` 45 | 46 | The `Within()` method of the grid allows you to iterate through a set of tiles within a bounding box, specified by the top-left and bottom-right points. It also takes an iterator function which is then invoked on every tile matching the filter. 47 | 48 | ```go 49 | grid.Within(At(1, 1), At(5, 5), func(p Point, t tile.Tile[string]) { 50 | // ... 51 | }) 52 | ``` 53 | 54 | The `At()` method of the grid allows you to retrieve a tile at a specific `x,y` coordinate. It simply returns the tile and whether it was found in the grid or not. 55 | 56 | ```go 57 | if tile, ok := grid.At(50, 100); ok { 58 | // ... 59 | } 60 | ``` 61 | 62 | The `WriteAt()` method of the grid allows you to update a tile at a specific `x,y` coordinate. Since the `Grid` itself is thread-safe, this is the way to (a) make sure the tile update/read is not racing and (b) notify observers of a tile update (more about this below). 63 | 64 | ```go 65 | grid.WriteAt(50, 100, tile.Value(0xFF)) 66 | ``` 67 | 68 | The `Neighbors()` method of the grid allows you to get the direct neighbors at a particular `x,y` coordinate and it takes an iterator funcion which is called for each neighbor. In this implementation, we are only taking direct neighbors (top, left, bottom, right). You rarely will need to use this method, unless you are rolling out your own pathfinding algorithm. 69 | 70 | ```go 71 | grid.Neighbors(50, 100, func(point tile.Point, t tile.Tile[string]) { 72 | // ... 73 | }) 74 | ``` 75 | 76 | The `MergeAt()` method of the grid allows you to atomically update a value given a current value of the tile. For example, if we want to increment the value of a tile we can call this method with a function that increments the value. Under the hood, the increment will be done using an atomic compare-and-swap operation. 77 | 78 | ```go 79 | grid.MergeAt(50, 100, func(v Value) Value { 80 | v += 1 81 | return v 82 | }) 83 | ``` 84 | 85 | The `MaskAt()` method of the grid allows you to atomically update only some of the bits at a particular `x,y` coordinate. This operation is as well thread-safe, and is actually useful when you might have multiple goroutines updating a set of tiles, but various goroutines are responsible for the various parts of the tile data. You might have a system that updates only a first couple of tile flags and another system updates some other bits. By using this method, two goroutines can update the different bits of the same tile concurrently, without erasing each other's results, which would happen if you just call `WriteAt()`. 86 | 87 | ```go 88 | // assume byte[0] of the tile is 0b01010001 89 | grid.MaskAt(50, 100, 90 | 0b00101110, // Only last 2 bits matter 91 | 0b00000011 // Mask specifies that we want to update last 2 bits 92 | ) 93 | 94 | // If the original is currently: 0b01010001 95 | // ...the result result will be: 0b01010010 96 | ``` 97 | 98 | # Pathfinding 99 | 100 | As mentioned in the introduction, this library provides a few grid search / pathfinding functions as well. They are implemented as methods on the same `Grid` structure as the rest of the functionnality. The main difference is that they may require some allocations (I'll try to minimize it further in the future), and require a cost function `func(Tile) uint16` which returns a "cost" of traversing a specific tile. For example if the tile is a "swamp" in your game, it may cost higher than moving on a "plain" tile. If the cost function returns `0`, the tile is then considered to be an impassable obstacle, which is a good choice for walls and such. 101 | 102 | The `Path()` method is used for finding a way between 2 points, you provide it the from/to point as well as costing function and it returns the path, calculated cost and whether a path was found or not. Note of caution however, avoid running it between 2 points if no path exists, since it might need to scan the entire map to figure that out with the current implementation. 103 | 104 | ```go 105 | from := At(1, 1) 106 | goal := At(7, 7) 107 | path, distance, found := m.Path(from, goal, func(v tile.Value) uint16{ 108 | if isImpassable(v) { 109 | return 0 110 | } 111 | return 1 112 | }) 113 | ``` 114 | 115 | The `Around()` method provides you with the ability to do a breadth-first search around a point, by providing a limit distance for the search as well as a cost function and an iterator. This is a handy way of finding things that are around the player in your game. 116 | 117 | ```go 118 | point := At(50, 50) 119 | radius := 5 120 | m.Around(point, radius, func(v tile.Value) uint16{ 121 | if isImpassable(v) { 122 | return 0 123 | } 124 | return 1 125 | }, func(p tile.Point, t tile.Tile[string]) { 126 | // ... tile found 127 | }) 128 | ``` 129 | 130 | # Observers 131 | 132 | Given that the `Grid` is mutable and you can make changes to it from various goroutines, I have implemented a way to "observe" tile changes through a `NewView()` method which creates an `Observer` and can be used to observe changes within a bounding box. For example, you might want your player to have a view port and be notified if something changes on the map so you can do something about it. 133 | 134 | In order to use these observers, you need to first call the `NewView()` function and start polling from the `Inbox` channel which will contain the tile update notifications as they happen. This channel has a small buffer, but if not read it will block the update, so make sure you always poll everything from it. Note that `NewView[S, T]` takes two type parameters, the first one is the type of the state object and the second one is the type of the tile value. The state object is used to store additional information about the view itself, such as the name of the view or a pointer to a socket that is used to send updates to the client. 135 | 136 | In the example below we create a new 20x20 view on the grid and iterate through all of the tiles in the view. 137 | 138 | ```go 139 | view := tile.NewView[string, string](grid, "My View #1") 140 | view.Resize(tile.NewRect(0, 0, 20, 20), func(p tile.Point, t tile.Tile){ 141 | // Optional, all of the tiles that are in the view now 142 | }) 143 | 144 | // Poll the inbox (in reality this would need to be with a select, and a goroutine) 145 | for { 146 | update := <-view.Inbox 147 | // Do something with update.Point, update.Tile 148 | } 149 | ``` 150 | 151 | The `MoveBy()` method allows you to move the view in a specific direction. It takes in a `x,y` vector but it can contain negative values. In the example below, we move the view upwards by 5 tiles. In addition, we can also provide an iterator and do something with all of the tiles that have entered the view (e.g. show them to the player). 152 | 153 | ```go 154 | view.MoveBy(0, 5, func(p tile.Point, tile tile.Tile){ 155 | // Every tile which entered our view 156 | }) 157 | ``` 158 | 159 | Similarly, `MoveAt()` method allows you to move the view at a specific location provided by the coordinates. The size of the view stays the same and the iterator will be called for all of the new tiles that have entered the view port. 160 | 161 | ```go 162 | view.MoveAt(At(10, 10), func(p tile.Point, t tile.Tile){ 163 | // Every tile which entered our view 164 | }) 165 | ``` 166 | 167 | The `Resize()` method allows you to resize and update the view port. As usual, the iterator will be called for all of the new tiles that have entered the view port. 168 | 169 | ```go 170 | viewRect := tile.NewRect(10, 10, 30, 30) 171 | view.Resize(viewRect, func(p tile.Point, t tile.Tile){ 172 | // Every tile which entered our view 173 | }) 174 | ``` 175 | 176 | The `Close()` method should be called when you are done with the view, since it unsubscribes all of the notifications. Be careful, if you do not close the view when you are done with it, it will lead to memory leaks since it will continue to observe the grid and receive notifications. 177 | 178 | ```go 179 | // Unsubscribe from notifications and close the view 180 | view.Close() 181 | ``` 182 | 183 | # Save & Load 184 | 185 | The library also provides a way to save the `Grid` to an `io.Writer` and load it from an `io.Reader` by using `WriteTo()` method and `ReadFrom()` function. Keep in mind that the save/load mechanism does not do any compression, but in practice you should [use to a compressor](https://github.com/klauspost/compress) if you want your maps to not take too much of the disk space - snappy is a good option for this since it's fast and compresses relatively well. 186 | 187 | The `WriteTo()` method of the grid only requires a specific `io.Writer` to be passed and returns a number of bytes that have been written down to it as well if any specific error has occured. Below is an example of how to save the grid into a compressed buffer. 188 | 189 | ```go 190 | // Prepare the output buffer and compressor 191 | output := new(bytes.Buffer) 192 | writer, err := flate.NewWriter(output, flate.BestSpeed) 193 | if err != nil { 194 | // ... 195 | } 196 | 197 | defer writer.Close() // Make sure we flush the compressor 198 | _, err := grid.WriteTo(writer) // Write the grid 199 | if err != nil { 200 | // ... 201 | } 202 | ``` 203 | 204 | The `ReadFrom()` function allows you to read the `Grid` from a particular reader. To complement the example above, the one below shows how to read a compressed grid using this function. 205 | 206 | ```go 207 | // Prepare a compressed reader over the buffer 208 | reader := flate.NewReader(output) 209 | 210 | // Read the Grid 211 | grid, err := ReadFrom(reader) 212 | if err != nil{ 213 | // ... 214 | } 215 | ``` 216 | 217 | # Benchmarks 218 | 219 | This library contains quite a bit of various micro-benchmarks to make sure that everything stays pretty fast. Feel free to clone and play around with them yourself. Below are the benchmarks which we have, most of them are running on relatively large grids. 220 | 221 | ``` 222 | cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz 223 | BenchmarkGrid/each-8 868 1358434 ns/op 0 B/op 0 allocs/op 224 | BenchmarkGrid/neighbors-8 66551679 17.87 ns/op 0 B/op 0 allocs/op 225 | BenchmarkGrid/within-8 27207 44753 ns/op 0 B/op 0 allocs/op 226 | BenchmarkGrid/at-8 399067512 2.994 ns/op 0 B/op 0 allocs/op 227 | BenchmarkGrid/write-8 130207965 9.294 ns/op 0 B/op 0 allocs/op 228 | BenchmarkGrid/merge-8 124156794 9.663 ns/op 0 B/op 0 allocs/op 229 | BenchmarkGrid/mask-8 100000000 10.67 ns/op 0 B/op 0 allocs/op 230 | BenchmarkState/range-8 12106854 98.91 ns/op 0 B/op 0 allocs/op 231 | BenchmarkState/add-8 48827727 25.43 ns/op 0 B/op 0 allocs/op 232 | BenchmarkState/del-8 52110474 21.59 ns/op 0 B/op 0 allocs/op 233 | BenchmarkPath/9x9-8 264586 4656 ns/op 16460 B/op 3 allocs/op 234 | BenchmarkPath/300x300-8 601 1937662 ns/op 7801502 B/op 4 allocs/op 235 | BenchmarkPath/381x381-8 363 3304134 ns/op 62394356 B/op 5 allocs/op 236 | BenchmarkPath/384x384-8 171 7165777 ns/op 62394400 B/op 5 allocs/op 237 | BenchmarkPath/3069x3069-8 31 36479106 ns/op 124836075 B/op 4 allocs/op 238 | BenchmarkPath/3072x3072-8 30 34889740 ns/op 124837686 B/op 4 allocs/op 239 | BenchmarkPath/6144x6144-8 142 7594013 ns/op 62395376 B/op 3 allocs/op 240 | BenchmarkAround/3r-8 506857 2384 ns/op 385 B/op 1 allocs/op 241 | BenchmarkAround/5r-8 214280 5539 ns/op 922 B/op 2 allocs/op 242 | BenchmarkAround/10r-8 85723 14017 ns/op 3481 B/op 2 allocs/op 243 | BenchmarkPoint/within-8 1000000000 0.2190 ns/op 0 B/op 0 allocs/op 244 | BenchmarkPoint/within-rect-8 1000000000 0.2195 ns/op 0 B/op 0 allocs/op 245 | BenchmarkStore/save-8 14577 82510 ns/op 8 B/op 1 allocs/op 246 | BenchmarkStore/read-8 3199 364771 ns/op 647419 B/op 7 allocs/op 247 | BenchmarkView/write-8 6285351 188.2 ns/op 48 B/op 1 allocs/op 248 | BenchmarkView/move-8 10000 116953 ns/op 0 B/op 0 allocs/op 249 | ``` 250 | 251 | # Contributing 252 | 253 | We are open to contributions, feel free to submit a pull request and we'll review it as quickly as we can. This library is maintained by [Roman Atachiants](https://www.linkedin.com/in/atachiants/) 254 | 255 | ## License 256 | 257 | Tile is licensed under the [MIT License](LICENSE.md). 258 | -------------------------------------------------------------------------------- /fixtures/300x300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelindar/tile/754fe1b62b50b0b8cb8bef04743de4b9f0644d24/fixtures/300x300.png -------------------------------------------------------------------------------- /fixtures/9x9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelindar/tile/754fe1b62b50b0b8cb8bef04743de4b9f0644d24/fixtures/9x9.png -------------------------------------------------------------------------------- /fixtures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelindar/tile/754fe1b62b50b0b8cb8bef04743de4b9f0644d24/fixtures/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kelindar/tile 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/kelindar/intmap v1.5.0 7 | github.com/kelindar/iostream v1.4.0 8 | github.com/stretchr/testify v1.9.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kelindar/intmap v1.5.0 h1:VY+AdO4Wx1sF1vGiTkS8n2lxhmFgOQwCIFuePQP4Iqw= 4 | github.com/kelindar/intmap v1.5.0/go.mod h1:NkypxhfaklmDTJqwano3Q1BWk6je77qgQwszDwu8Kc8= 5 | github.com/kelindar/iostream v1.4.0 h1:ELKlinnM/K3GbRp9pYhWuZOyBxMMlYAfsOP+gauvZaY= 6 | github.com/kelindar/iostream v1.4.0/go.mod h1:MkjMuVb6zGdPQVdwLnFRO0xOTOdDvBWTztFmjRDQkXk= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /grid.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "sync" 8 | "sync/atomic" 9 | ) 10 | 11 | // Grid represents a 2D tile map. Internally, a map is composed of 3x3 pages. 12 | type Grid[T comparable] struct { 13 | pages []page[T] // The pages of the map 14 | pageWidth int16 // The max page width 15 | pageHeight int16 // The max page height 16 | observers pubsub[T] // The map of observers 17 | Size Point // The map size 18 | } 19 | 20 | // NewGrid returns a new map of the specified size. The width and height must be both 21 | // multiples of 3. 22 | func NewGrid(width, height int16) *Grid[string] { 23 | return NewGridOf[string](width, height) 24 | } 25 | 26 | // NewGridOf returns a new map of the specified size. The width and height must be both 27 | // multiples of 3. 28 | func NewGridOf[T comparable](width, height int16) *Grid[T] { 29 | width, height = width/3, height/3 30 | 31 | max := int32(width) * int32(height) 32 | pages := make([]page[T], max) 33 | m := &Grid[T]{ 34 | pages: pages, 35 | pageWidth: width, 36 | pageHeight: height, 37 | Size: At(width*3, height*3), 38 | observers: pubsub[T]{ 39 | tmp: sync.Pool{ 40 | New: func() any { return make(map[Observer[T]]struct{}, 4) }, 41 | }, 42 | }, 43 | } 44 | 45 | // Function to calculate a point based on the index 46 | var pointAt func(i int) Point = func(i int) Point { 47 | return At(int16(i%int(width)), int16(i/int(width))) 48 | } 49 | 50 | for i := 0; i < int(max); i++ { 51 | pages[i].point = pointAt(i).MultiplyScalar(3) 52 | } 53 | return m 54 | } 55 | 56 | // Each iterates over all of the tiles in the map. 57 | func (m *Grid[T]) Each(fn func(Point, Tile[T])) { 58 | until := int(m.pageHeight) * int(m.pageWidth) 59 | for i := 0; i < until; i++ { 60 | m.pages[i].Each(m, fn) 61 | } 62 | } 63 | 64 | // Within selects the tiles within a specifid bounding box which is specified by 65 | // north-west and south-east coordinates. 66 | func (m *Grid[T]) Within(nw, se Point, fn func(Point, Tile[T])) { 67 | m.pagesWithin(nw, se, func(page *page[T]) { 68 | page.Each(m, func(p Point, v Tile[T]) { 69 | if p.Within(nw, se) { 70 | fn(p, v) 71 | } 72 | }) 73 | }) 74 | } 75 | 76 | // pagesWithin selects the pages within a specifid bounding box which is specified 77 | // by north-west and south-east coordinates. 78 | func (m *Grid[T]) pagesWithin(nw, se Point, fn func(*page[T])) { 79 | if !se.WithinSize(m.Size) { 80 | se = At(m.Size.X-1, m.Size.Y-1) 81 | } 82 | 83 | for x := nw.X / 3; x <= se.X/3; x++ { 84 | for y := nw.Y / 3; y <= se.Y/3; y++ { 85 | fn(m.pageAt(x, y)) 86 | } 87 | } 88 | } 89 | 90 | // At returns the tile at a specified position 91 | func (m *Grid[T]) At(x, y int16) (Tile[T], bool) { 92 | if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { 93 | return m.pageAt(x/3, y/3).At(m, x, y), true 94 | } 95 | 96 | return Tile[T]{}, false 97 | } 98 | 99 | // WriteAt updates the entire tile value at a specific coordinate 100 | func (m *Grid[T]) WriteAt(x, y int16, tile Value) { 101 | if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { 102 | m.pageAt(x/3, y/3).writeTile(m, uint8((y%3)*3+(x%3)), tile) 103 | } 104 | } 105 | 106 | // MaskAt atomically updates the bits of tile at a specific coordinate. The bits are 107 | // specified by the mask. The bits that need to be updated should be flipped on in the mask. 108 | func (m *Grid[T]) MaskAt(x, y int16, tile, mask Value) { 109 | m.MergeAt(x, y, func(value Value) Value { 110 | return (value &^ mask) | (tile & mask) 111 | }) 112 | } 113 | 114 | // Merge atomically merges the tile by applying a merging function at a specific coordinate. 115 | func (m *Grid[T]) MergeAt(x, y int16, merge func(Value) Value) { 116 | if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { 117 | m.pageAt(x/3, y/3).mergeTile(m, uint8((y%3)*3+(x%3)), merge) 118 | } 119 | } 120 | 121 | // Neighbors iterates over the direct neighbouring tiles 122 | func (m *Grid[T]) Neighbors(x, y int16, fn func(Point, Tile[T])) { 123 | 124 | // First we need to figure out which pages contain the neighboring tiles and 125 | // then load them. In the best-case we need to load only a single page. In 126 | // the worst-case: we need to load 3 pages. 127 | nX, nY := x/3, (y-1)/3 // North 128 | eX, eY := (x+1)/3, y/3 // East 129 | sX, sY := x/3, (y+1)/3 // South 130 | wX, wY := (x-1)/3, y/3 // West 131 | 132 | // Get the North 133 | if y > 0 { 134 | fn(At(x, y-1), m.pageAt(nX, nY).At(m, x, y-1)) 135 | } 136 | 137 | // Get the East 138 | if eX < m.pageWidth { 139 | fn(At(x+1, y), m.pageAt(eX, eY).At(m, x+1, y)) 140 | } 141 | 142 | // Get the South 143 | if sY < m.pageHeight { 144 | fn(At(x, y+1), m.pageAt(sX, sY).At(m, x, y+1)) 145 | } 146 | 147 | // Get the West 148 | if x > 0 { 149 | fn(At(x-1, y), m.pageAt(wX, wY).At(m, x-1, y)) 150 | } 151 | } 152 | 153 | // pageAt loads a page at a given page location 154 | func (m *Grid[T]) pageAt(x, y int16) *page[T] { 155 | index := int(x) + int(m.pageWidth)*int(y) 156 | 157 | // Eliminate bounds checks 158 | if index >= 0 && index < len(m.pages) { 159 | return &m.pages[index] 160 | } 161 | 162 | return nil 163 | } 164 | 165 | // ---------------------------------- Tile ---------------------------------- 166 | 167 | // Value represents a packed tile information, it must fit on 4 bytes. 168 | type Value = uint32 169 | 170 | // ---------------------------------- Page ---------------------------------- 171 | 172 | // page represents a 3x3 tile page each page should neatly fit on a cache 173 | // line and speed things up. 174 | type page[T comparable] struct { 175 | mu sync.Mutex // State lock, 8 bytes 176 | state map[T]uint8 // State data, 8 bytes 177 | flags uint32 // Page flags, 4 bytes 178 | point Point // Page X, Y coordinate, 4 bytes 179 | tiles [9]Value // Page tiles, 36 bytes 180 | } 181 | 182 | // tileAt reads a tile at a page index 183 | func (p *page[T]) tileAt(idx uint8) Value { 184 | return Value(atomic.LoadUint32((*uint32)(&p.tiles[idx]))) 185 | } 186 | 187 | // IsObserved returns whether the tile is observed or not 188 | func (p *page[T]) IsObserved() bool { 189 | return (atomic.LoadUint32(&p.flags))&1 != 0 190 | } 191 | 192 | // Bounds returns the bounding box for the tile page. 193 | func (p *page[T]) Bounds() Rect { 194 | return Rect{p.point, At(p.point.X+3, p.point.Y+3)} 195 | } 196 | 197 | // At returns a cursor at a specific coordinate 198 | func (p *page[T]) At(grid *Grid[T], x, y int16) Tile[T] { 199 | return Tile[T]{grid: grid, data: p, idx: uint8((y%3)*3 + (x % 3))} 200 | } 201 | 202 | // Each iterates over all of the tiles in the page. 203 | func (p *page[T]) Each(grid *Grid[T], fn func(Point, Tile[T])) { 204 | x, y := p.point.X, p.point.Y 205 | fn(Point{x, y}, Tile[T]{grid: grid, data: p, idx: 0}) // NW 206 | fn(Point{x + 1, y}, Tile[T]{grid: grid, data: p, idx: 1}) // N 207 | fn(Point{x + 2, y}, Tile[T]{grid: grid, data: p, idx: 2}) // NE 208 | fn(Point{x, y + 1}, Tile[T]{grid: grid, data: p, idx: 3}) // W 209 | fn(Point{x + 1, y + 1}, Tile[T]{grid: grid, data: p, idx: 4}) // C 210 | fn(Point{x + 2, y + 1}, Tile[T]{grid: grid, data: p, idx: 5}) // E 211 | fn(Point{x, y + 2}, Tile[T]{grid: grid, data: p, idx: 6}) // SW 212 | fn(Point{x + 1, y + 2}, Tile[T]{grid: grid, data: p, idx: 7}) // S 213 | fn(Point{x + 2, y + 2}, Tile[T]{grid: grid, data: p, idx: 8}) // SE 214 | } 215 | 216 | // SetObserved sets the observed flag on the page 217 | func (p *page[T]) SetObserved(observed bool) { 218 | const flagObserved = 0x1 219 | for { 220 | value := atomic.LoadUint32(&p.flags) 221 | merge := value 222 | if observed { 223 | merge = value | flagObserved 224 | } else { 225 | merge = value &^ flagObserved 226 | } 227 | 228 | if atomic.CompareAndSwapUint32(&p.flags, value, merge) { 229 | break 230 | } 231 | } 232 | } 233 | 234 | // Lock locks the state. Note: this needs to be named Lock() so go vet will 235 | // complain if the page is copied around. 236 | func (p *page[T]) Lock() { 237 | p.mu.Lock() 238 | } 239 | 240 | // Unlock unlocks the state. Note: this needs to be named Unlock() so go vet will 241 | // complain if the page is copied around. 242 | func (p *page[T]) Unlock() { 243 | p.mu.Unlock() 244 | } 245 | 246 | // ---------------------------------- Mutations ---------------------------------- 247 | 248 | // writeTile stores the tile and return whether tile is observed or not 249 | func (p *page[T]) writeTile(grid *Grid[T], idx uint8, after Value) { 250 | before := p.tileAt(idx) 251 | for !atomic.CompareAndSwapUint32(&p.tiles[idx], uint32(before), uint32(after)) { 252 | before = p.tileAt(idx) 253 | } 254 | 255 | // If observed, notify the observers of the tile 256 | if p.IsObserved() { 257 | at := pointOf(p.point, idx) 258 | grid.observers.Notify1(&Update[T]{ 259 | Old: ValueAt{ 260 | Point: at, 261 | Value: before, 262 | }, 263 | New: ValueAt{ 264 | Point: at, 265 | Value: after, 266 | }, 267 | }, p.point) 268 | } 269 | } 270 | 271 | // mergeTile atomically merges the tile bits given a function 272 | func (p *page[T]) mergeTile(grid *Grid[T], idx uint8, fn func(Value) Value) Value { 273 | before := p.tileAt(idx) 274 | after := fn(before) 275 | 276 | // Swap, if we're not able to re-merge again 277 | for !atomic.CompareAndSwapUint32(&p.tiles[idx], uint32(before), uint32(after)) { 278 | before = p.tileAt(idx) 279 | after = fn(before) 280 | } 281 | 282 | // If observed, notify the observers of the tile 283 | if p.IsObserved() { 284 | at := pointOf(p.point, idx) 285 | grid.observers.Notify1(&Update[T]{ 286 | Old: ValueAt{ 287 | Point: at, 288 | Value: before, 289 | }, 290 | New: ValueAt{ 291 | Point: at, 292 | Value: after, 293 | }, 294 | }, p.point) 295 | } 296 | 297 | // Return the merged tile data 298 | return after 299 | } 300 | 301 | // addObject adds object to the set 302 | func (p *page[T]) addObject(idx uint8, object T) (value uint32) { 303 | p.Lock() 304 | 305 | // Lazily initialize the map, as most pages might not have anything stored 306 | // in them (e.g. water or empty tile) 307 | if p.state == nil { 308 | p.state = make(map[T]uint8) 309 | } 310 | 311 | p.state[object] = uint8(idx) 312 | value = p.tileAt(idx) 313 | p.Unlock() 314 | return 315 | } 316 | 317 | // delObject removes the object from the set 318 | func (p *page[T]) delObject(idx uint8, object T) (value uint32) { 319 | p.Lock() 320 | if p.state != nil { 321 | delete(p.state, object) 322 | } 323 | value = p.tileAt(idx) 324 | p.Unlock() 325 | return 326 | } 327 | 328 | // ---------------------------------- Tile Cursor ---------------------------------- 329 | 330 | // Tile represents an iterator over all state objects at a particular location. 331 | type Tile[T comparable] struct { 332 | grid *Grid[T] // grid pointer 333 | data *page[T] // page pointer 334 | idx uint8 // tile index 335 | } 336 | 337 | // Count returns number of objects at the current tile. 338 | func (t Tile[T]) Count() (count int) { 339 | t.data.Lock() 340 | defer t.data.Unlock() 341 | for _, idx := range t.data.state { 342 | if idx == uint8(t.idx) { 343 | count++ 344 | } 345 | } 346 | return 347 | } 348 | 349 | // Point returns the point of the tile 350 | func (t Tile[T]) Point() Point { 351 | return pointOf(t.data.point, t.idx) 352 | } 353 | 354 | // Value reads the tile information 355 | func (t Tile[T]) Value() Value { 356 | return t.data.tileAt(t.idx) 357 | } 358 | 359 | // Range iterates over all of the objects in the set 360 | func (t Tile[T]) Range(fn func(T) error) error { 361 | t.data.Lock() 362 | defer t.data.Unlock() 363 | for v, idx := range t.data.state { 364 | if idx == uint8(t.idx) { 365 | if err := fn(v); err != nil { 366 | return err 367 | } 368 | } 369 | } 370 | return nil 371 | } 372 | 373 | // Observers iterates over all views observing this tile 374 | func (t Tile[T]) Observers(fn func(view Observer[T])) { 375 | if !t.data.IsObserved() { 376 | return 377 | } 378 | 379 | t.grid.observers.Each1(func(sub Observer[T]) { 380 | if sub.Viewport().Contains(t.Point()) { 381 | fn(sub) 382 | } 383 | }, t.data.point) 384 | } 385 | 386 | // Add adds object to the set 387 | func (t Tile[T]) Add(v T) { 388 | value := t.data.addObject(t.idx, v) 389 | 390 | // If observed, notify the observers of the tile 391 | if t.data.IsObserved() { 392 | at := t.Point() 393 | t.grid.observers.Notify1(&Update[T]{ 394 | Old: ValueAt{ 395 | Point: at, 396 | Value: value, 397 | }, 398 | New: ValueAt{ 399 | Point: at, 400 | Value: value, 401 | }, 402 | Add: v, 403 | }, t.data.point) 404 | } 405 | } 406 | 407 | // Del removes the object from the set 408 | func (t Tile[T]) Del(v T) { 409 | value := t.data.delObject(t.idx, v) 410 | 411 | // If observed, notify the observers of the tile 412 | if t.data.IsObserved() { 413 | at := t.Point() 414 | t.grid.observers.Notify1(&Update[T]{ 415 | Old: ValueAt{ 416 | Point: at, 417 | Value: value, 418 | }, 419 | New: ValueAt{ 420 | Point: at, 421 | Value: value, 422 | }, 423 | Del: v, 424 | }, t.data.point) 425 | } 426 | } 427 | 428 | // Move moves an object from the current tile to the destination tile. 429 | func (t Tile[T]) Move(v T, dst Point) bool { 430 | d, ok := t.grid.At(dst.X, dst.Y) 431 | if !ok { 432 | return false 433 | } 434 | 435 | // Move the object from the source to the destination 436 | tv := t.data.delObject(d.idx, v) 437 | dv := d.data.addObject(d.idx, v) 438 | if !t.data.IsObserved() && !d.data.IsObserved() { 439 | return true 440 | } 441 | 442 | // Prepare the update notification 443 | update := &Update[T]{ 444 | Old: ValueAt{ 445 | Point: t.Point(), 446 | Value: tv, 447 | }, 448 | New: ValueAt{ 449 | Point: d.Point(), 450 | Value: dv, 451 | }, 452 | Del: v, 453 | Add: v, 454 | } 455 | 456 | switch { 457 | case t.data == d.data || !d.data.IsObserved(): 458 | t.grid.observers.Notify1(update, t.data.point) 459 | case !t.data.IsObserved(): 460 | t.grid.observers.Notify1(update, d.data.point) 461 | default: 462 | t.grid.observers.Notify2(update, [2]Point{ 463 | t.data.point, 464 | d.data.point, 465 | }) 466 | } 467 | return true 468 | } 469 | 470 | // Write updates the entire tile value. 471 | func (t Tile[T]) Write(tile Value) { 472 | t.data.writeTile(t.grid, t.idx, tile) 473 | } 474 | 475 | // Merge atomically merges the tile by applying a merging function. 476 | func (t Tile[T]) Merge(merge func(Value) Value) Value { 477 | return t.data.mergeTile(t.grid, t.idx, merge) 478 | } 479 | 480 | // Mask updates the bits of tile. The bits are specified by the mask. The bits 481 | // that need to be updated should be flipped on in the mask. 482 | func (t Tile[T]) Mask(tile, mask Value) Value { 483 | return t.data.mergeTile(t.grid, t.idx, func(value Value) Value { 484 | return (value &^ mask) | (tile & mask) 485 | }) 486 | } 487 | 488 | // pointOf returns the point given an index 489 | func pointOf(page Point, idx uint8) Point { 490 | return Point{ 491 | X: page.X + int16(idx)%3, 492 | Y: page.Y + int16(idx)/3, 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /grid_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "sync" 10 | "testing" 11 | "unsafe" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | /* 17 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 18 | BenchmarkGrid/each-24 1452 830268 ns/op 0 B/op 0 allocs/op 19 | BenchmarkGrid/neighbors-24 121583491 9.861 ns/op 0 B/op 0 allocs/op 20 | BenchmarkGrid/within-24 49360 24477 ns/op 0 B/op 0 allocs/op 21 | BenchmarkGrid/at-24 687659378 1.741 ns/op 0 B/op 0 allocs/op 22 | BenchmarkGrid/write-24 191272338 6.307 ns/op 0 B/op 0 allocs/op 23 | BenchmarkGrid/merge-24 162536985 7.332 ns/op 0 B/op 0 allocs/op 24 | BenchmarkGrid/mask-24 158258084 7.601 ns/op 0 B/op 0 allocs/op 25 | */ 26 | func BenchmarkGrid(b *testing.B) { 27 | var d Tile[uint32] 28 | var p Point 29 | defer assert.NotNil(b, d) 30 | m := NewGridOf[uint32](768, 768) 31 | 32 | b.Run("each", func(b *testing.B) { 33 | b.ReportAllocs() 34 | b.ResetTimer() 35 | for n := 0; n < b.N; n++ { 36 | m.Each(func(point Point, tile Tile[uint32]) { 37 | p = point 38 | d = tile 39 | }) 40 | } 41 | }) 42 | 43 | b.Run("neighbors", func(b *testing.B) { 44 | b.ReportAllocs() 45 | b.ResetTimer() 46 | for n := 0; n < b.N; n++ { 47 | m.Neighbors(300, 300, func(point Point, tile Tile[uint32]) { 48 | p = point 49 | d = tile 50 | }) 51 | } 52 | }) 53 | 54 | b.Run("within", func(b *testing.B) { 55 | b.ReportAllocs() 56 | b.ResetTimer() 57 | for n := 0; n < b.N; n++ { 58 | m.Within(At(100, 100), At(200, 200), func(point Point, tile Tile[uint32]) { 59 | p = point 60 | d = tile 61 | }) 62 | } 63 | }) 64 | 65 | assert.NotZero(b, p.X) 66 | b.Run("at", func(b *testing.B) { 67 | b.ReportAllocs() 68 | b.ResetTimer() 69 | for n := 0; n < b.N; n++ { 70 | d, _ = m.At(100, 100) 71 | } 72 | }) 73 | 74 | b.Run("write", func(b *testing.B) { 75 | b.ReportAllocs() 76 | b.ResetTimer() 77 | for n := 0; n < b.N; n++ { 78 | m.WriteAt(100, 100, Value(0)) 79 | } 80 | }) 81 | 82 | b.Run("merge", func(b *testing.B) { 83 | b.ReportAllocs() 84 | b.ResetTimer() 85 | for n := 0; n < b.N; n++ { 86 | m.MergeAt(100, 100, func(v Value) Value { 87 | v += 1 88 | return v 89 | }) 90 | } 91 | }) 92 | 93 | b.Run("mask", func(b *testing.B) { 94 | b.ReportAllocs() 95 | b.ResetTimer() 96 | for n := 0; n < b.N; n++ { 97 | m.MaskAt(100, 100, Value(0), Value(1)) 98 | } 99 | }) 100 | } 101 | 102 | /* 103 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 104 | BenchmarkState/range-24 17017800 71.14 ns/op 0 B/op 0 allocs/op 105 | BenchmarkState/add-24 72639224 16.32 ns/op 0 B/op 0 allocs/op 106 | BenchmarkState/del-24 82469125 13.65 ns/op 0 B/op 0 allocs/op 107 | */ 108 | func BenchmarkState(b *testing.B) { 109 | m := NewGridOf[int](768, 768) 110 | m.Each(func(p Point, c Tile[int]) { 111 | for i := 0; i < 10; i++ { 112 | c.Add(i) 113 | } 114 | }) 115 | 116 | b.Run("range", func(b *testing.B) { 117 | b.ReportAllocs() 118 | b.ResetTimer() 119 | for n := 0; n < b.N; n++ { 120 | cursor, _ := m.At(100, 100) 121 | cursor.Range(func(v int) error { 122 | return nil 123 | }) 124 | } 125 | }) 126 | 127 | b.Run("add", func(b *testing.B) { 128 | b.ReportAllocs() 129 | b.ResetTimer() 130 | for n := 0; n < b.N; n++ { 131 | cursor, _ := m.At(100, 100) 132 | cursor.Add(100) 133 | } 134 | }) 135 | 136 | b.Run("del", func(b *testing.B) { 137 | b.ReportAllocs() 138 | b.ResetTimer() 139 | for n := 0; n < b.N; n++ { 140 | cursor, _ := m.At(100, 100) 141 | cursor.Del(100) 142 | } 143 | }) 144 | } 145 | 146 | func TestPageSize(t *testing.T) { 147 | assert.Equal(t, 8, int(unsafe.Sizeof(map[uintptr]Point{}))) 148 | assert.Equal(t, 64, int(unsafe.Sizeof(page[string]{}))) 149 | assert.Equal(t, 36, int(unsafe.Sizeof([9]Value{}))) 150 | } 151 | 152 | func TestWithin(t *testing.T) { 153 | m := NewGrid(9, 9) 154 | 155 | var path []string 156 | m.Within(At(1, 1), At(5, 5), func(p Point, tile Tile[string]) { 157 | path = append(path, p.String()) 158 | }) 159 | assert.Equal(t, 16, len(path)) 160 | assert.ElementsMatch(t, []string{ 161 | "1,1", "2,1", "1,2", "2,2", 162 | "3,1", "4,1", "3,2", "4,2", 163 | "1,3", "2,3", "1,4", "2,4", 164 | "3,3", "4,3", "3,4", "4,4", 165 | }, path) 166 | } 167 | 168 | func TestWithinCorner(t *testing.T) { 169 | m := NewGrid(9, 9) 170 | 171 | var path []string 172 | m.Within(At(7, 6), At(10, 10), func(p Point, tile Tile[string]) { 173 | path = append(path, p.String()) 174 | }) 175 | assert.Equal(t, 6, len(path)) 176 | assert.ElementsMatch(t, []string{ 177 | "7,6", "8,6", "7,7", 178 | "8,7", "7,8", "8,8", 179 | }, path) 180 | } 181 | 182 | func TestWithinXY(t *testing.T) { 183 | assert.False(t, At(4, 8).WithinRect(NewRect(1, 6, 4, 10))) 184 | } 185 | 186 | func TestWithinOneSide(t *testing.T) { 187 | m := NewGrid(9, 9) 188 | 189 | var path []string 190 | m.Within(At(1, 6), At(4, 10), func(p Point, tile Tile[string]) { 191 | path = append(path, p.String()) 192 | }) 193 | assert.Equal(t, 9, len(path)) 194 | assert.ElementsMatch(t, []string{ 195 | "1,6", "2,6", "3,6", 196 | "1,7", "2,7", "3,7", 197 | "1,8", "2,8", "3,8", 198 | }, path) 199 | } 200 | 201 | func TestWithinInvalid(t *testing.T) { 202 | m := NewGrid(9, 9) 203 | count := 0 204 | m.Within(At(10, 10), At(20, 20), func(p Point, tile Tile[string]) { 205 | count++ 206 | }) 207 | assert.Equal(t, 0, count) 208 | } 209 | 210 | func TestEach(t *testing.T) { 211 | m := NewGrid(9, 9) 212 | 213 | var path []string 214 | m.Each(func(p Point, tile Tile[string]) { 215 | path = append(path, p.String()) 216 | }) 217 | assert.Equal(t, 81, len(path)) 218 | assert.ElementsMatch(t, []string{ 219 | "0,0", "1,0", "2,0", "0,1", "1,1", "2,1", "0,2", "1,2", "2,2", 220 | "0,3", "1,3", "2,3", "0,4", "1,4", "2,4", "0,5", "1,5", "2,5", 221 | "0,6", "1,6", "2,6", "0,7", "1,7", "2,7", "0,8", "1,8", "2,8", 222 | "3,0", "4,0", "5,0", "3,1", "4,1", "5,1", "3,2", "4,2", "5,2", 223 | "3,3", "4,3", "5,3", "3,4", "4,4", "5,4", "3,5", "4,5", "5,5", 224 | "3,6", "4,6", "5,6", "3,7", "4,7", "5,7", "3,8", "4,8", "5,8", 225 | "6,0", "7,0", "8,0", "6,1", "7,1", "8,1", "6,2", "7,2", "8,2", 226 | "6,3", "7,3", "8,3", "6,4", "7,4", "8,4", "6,5", "7,5", "8,5", 227 | "6,6", "7,6", "8,6", "6,7", "7,7", "8,7", "6,8", "7,8", "8,8", 228 | }, path) 229 | } 230 | 231 | func TestNeighbors(t *testing.T) { 232 | tests := []struct { 233 | x, y int16 234 | expect []string 235 | }{ 236 | {x: 0, y: 0, expect: []string{"1,0", "0,1"}}, 237 | {x: 1, y: 0, expect: []string{"2,0", "1,1", "0,0"}}, 238 | {x: 1, y: 1, expect: []string{"1,0", "2,1", "1,2", "0,1"}}, 239 | {x: 2, y: 2, expect: []string{"2,1", "3,2", "2,3", "1,2"}}, 240 | {x: 8, y: 8, expect: []string{"8,7", "7,8"}}, 241 | } 242 | 243 | // Create a 9x9 map with labeled tiles 244 | m := NewGrid(9, 9) 245 | m.Each(func(p Point, tile Tile[string]) { 246 | m.WriteAt(p.X, p.Y, Value(p.Integer())) 247 | }) 248 | 249 | // Run all the tests 250 | for _, tc := range tests { 251 | var out []string 252 | m.Neighbors(tc.x, tc.y, func(_ Point, tile Tile[string]) { 253 | loc := unpackPoint(uint32(tile.Value())) 254 | out = append(out, loc.String()) 255 | }) 256 | assert.ElementsMatch(t, tc.expect, out) 257 | } 258 | } 259 | 260 | func TestAt(t *testing.T) { 261 | 262 | // Create a 9x9 map with labeled tiles 263 | m := NewGrid(9, 9) 264 | m.Each(func(p Point, tile Tile[string]) { 265 | m.WriteAt(p.X, p.Y, Value(p.Integer())) 266 | }) 267 | 268 | // Make sure our At() and the position matches 269 | m.Each(func(p Point, tile Tile[string]) { 270 | at, _ := m.At(p.X, p.Y) 271 | assert.Equal(t, p.String(), unpackPoint(uint32(at.Value())).String()) 272 | }) 273 | 274 | // Make sure that points match 275 | for y := int16(0); y < 9; y++ { 276 | for x := int16(0); x < 9; x++ { 277 | at, _ := m.At(x, y) 278 | assert.Equal(t, At(x, y).String(), unpackPoint(uint32(at.Value())).String()) 279 | } 280 | } 281 | } 282 | 283 | func TestUpdate(t *testing.T) { 284 | 285 | // Create a 9x9 map with labeled tiles 286 | m := NewGrid(9, 9) 287 | i := 0 288 | m.Each(func(p Point, _ Tile[string]) { 289 | i++ 290 | m.WriteAt(p.X, p.Y, Value(i)) 291 | }) 292 | 293 | // Assert the update 294 | cursor, _ := m.At(8, 8) 295 | assert.Equal(t, 81, int(cursor.Value())) 296 | 297 | // 81 = 0b01010001 298 | delta := Value(0b00101110) // change last 2 bits and should ignore other bits 299 | m.MaskAt(8, 8, delta, Value(0b00000011)) 300 | 301 | // original: 0101 0001 302 | // delta: 0010 1110 303 | // mask: 0000 0011 304 | // result: 0101 0010 305 | cursor, _ = m.At(8, 8) 306 | assert.Equal(t, 0b01010010, int(cursor.Value())) 307 | } 308 | 309 | func TestState(t *testing.T) { 310 | m := NewGrid(9, 9) 311 | m.Each(func(p Point, c Tile[string]) { 312 | c.Add(p.String()) 313 | c.Add(p.String()) // duplicate 314 | }) 315 | 316 | m.Each(func(p Point, c Tile[string]) { 317 | assert.Equal(t, 1, c.Count()) 318 | assert.NoError(t, c.Range(func(s string) error { 319 | assert.Equal(t, p.String(), s) 320 | return nil 321 | })) 322 | 323 | c.Del(p.String()) 324 | assert.Equal(t, 0, c.Count()) 325 | }) 326 | } 327 | 328 | func TestStateRangeErr(t *testing.T) { 329 | m := NewGrid(9, 9) 330 | m.Each(func(p Point, c Tile[string]) { 331 | c.Add(p.String()) 332 | }) 333 | 334 | m.Each(func(p Point, c Tile[string]) { 335 | assert.Error(t, c.Range(func(s string) error { 336 | return io.EOF 337 | })) 338 | }) 339 | } 340 | 341 | func TestPointOf(t *testing.T) { 342 | truthTable := func(x, y int16, idx uint8) (int16, int16) { 343 | switch idx { 344 | case 0: 345 | return x, y 346 | case 1: 347 | return x + 1, y 348 | case 2: 349 | return x + 2, y 350 | case 3: 351 | return x, y + 1 352 | case 4: 353 | return x + 1, y + 1 354 | case 5: 355 | return x + 2, y + 1 356 | case 6: 357 | return x, y + 2 358 | case 7: 359 | return x + 1, y + 2 360 | case 8: 361 | return x + 2, y + 2 362 | default: 363 | return x, y 364 | } 365 | } 366 | 367 | for i := 0; i < 9; i++ { 368 | at := pointOf(At(0, 0), uint8(i)) 369 | x, y := truthTable(0, 0, uint8(i)) 370 | assert.Equal(t, x, at.X, fmt.Sprintf("idx=%v", i)) 371 | assert.Equal(t, y, at.Y, fmt.Sprintf("idx=%v", i)) 372 | } 373 | } 374 | 375 | func TestConcurrentMerge(t *testing.T) { 376 | const count = 10000 377 | var wg sync.WaitGroup 378 | wg.Add(count) 379 | 380 | m := NewGrid(9, 9) 381 | for i := 0; i < count; i++ { 382 | go func() { 383 | m.MergeAt(1, 1, func(v Value) Value { 384 | v += 1 385 | return v 386 | }) 387 | wg.Done() 388 | }() 389 | } 390 | 391 | wg.Wait() 392 | tile, ok := m.At(1, 1) 393 | assert.True(t, ok) 394 | assert.Equal(t, uint32(count), tile.Value()) 395 | } 396 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "math" 8 | "math/bits" 9 | "sync" 10 | 11 | "github.com/kelindar/intmap" 12 | ) 13 | 14 | type costFn = func(Value) uint16 15 | 16 | // Edge represents an edge of the path 17 | type edge struct { 18 | Point 19 | Cost uint32 20 | } 21 | 22 | // Around performs a breadth first search around a point. 23 | func (m *Grid[T]) Around(from Point, distance uint32, costOf costFn, fn func(Point, Tile[T])) { 24 | start, ok := m.At(from.X, from.Y) 25 | if !ok { 26 | return 27 | } 28 | 29 | fn(from, start) 30 | 31 | // For pre-allocating, we use πr2 since BFS will result in a approximation 32 | // of a circle, in the worst case. 33 | maxArea := int(math.Ceil(math.Pi * float64(distance*distance))) 34 | 35 | // Acquire a frontier heap for search 36 | state := acquire(maxArea) 37 | frontier := state.frontier 38 | reached := state.edges 39 | defer release(state) 40 | 41 | frontier.Push(from.Integer(), 0) 42 | reached.Store(from.Integer(), 0) 43 | for !frontier.IsEmpty() { 44 | pCurr := frontier.Pop() 45 | current := unpackPoint(pCurr) 46 | 47 | // Get all of the neighbors 48 | m.Neighbors(current.X, current.Y, func(next Point, nextTile Tile[T]) { 49 | if d := from.DistanceTo(next); d > distance { 50 | return // Too far 51 | } 52 | 53 | if cost := costOf(nextTile.Value()); cost == 0 { 54 | return // Blocked tile, ignore completely 55 | } 56 | 57 | // Add to the search queue 58 | pNext := next.Integer() 59 | if _, ok := reached.Load(pNext); !ok { 60 | frontier.Push(pNext, 1) 61 | reached.Store(pNext, 1) 62 | fn(next, nextTile) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | // Path calculates a short path and the distance between the two locations 69 | func (m *Grid[T]) Path(from, to Point, costOf costFn) ([]Point, int, bool) { 70 | distance := float64(from.DistanceTo(to)) 71 | maxArea := int(math.Ceil(math.Pi * float64(distance*distance))) 72 | 73 | // For pre-allocating, we use πr2 since BFS will result in a approximation 74 | // of a circle, in the worst case. 75 | state := acquire(maxArea) 76 | edges := state.edges 77 | frontier := state.frontier 78 | defer release(state) 79 | 80 | frontier.Push(from.Integer(), 0) 81 | edges.Store(from.Integer(), encode(0, Direction(0))) // Starting point has no direction 82 | 83 | for !frontier.IsEmpty() { 84 | pCurr := frontier.Pop() 85 | current := unpackPoint(pCurr) 86 | 87 | // Decode the cost to reach the current point 88 | currentEncoded, _ := edges.Load(pCurr) 89 | currentCost, _ := decode(currentEncoded) 90 | 91 | // Check if we've reached the destination 92 | if current.Equal(to) { 93 | 94 | // Reconstruct the path 95 | path := make([]Point, 0, 64) 96 | path = append(path, current) 97 | for !current.Equal(from) { 98 | currentEncoded, _ := edges.Load(current.Integer()) 99 | _, dir := decode(currentEncoded) 100 | current = current.Move(oppositeDirection(dir)) 101 | path = append(path, current) 102 | } 103 | 104 | // Reverse the path to get from source to destination 105 | for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { 106 | path[i], path[j] = path[j], path[i] 107 | } 108 | 109 | return path, int(currentCost), true 110 | } 111 | 112 | // Explore neighbors 113 | m.Neighbors(current.X, current.Y, func(next Point, nextTile Tile[T]) { 114 | cNext := costOf(nextTile.Value()) 115 | if cNext == 0 { 116 | return // Blocked tile 117 | } 118 | 119 | nextCost := currentCost + uint32(cNext) 120 | pNext := next.Integer() 121 | 122 | existingEncoded, visited := edges.Load(pNext) 123 | existingCost, _ := decode(existingEncoded) 124 | 125 | // If we haven't visited this node or we found a better path 126 | if !visited || nextCost < existingCost { 127 | angle := angleOf(current, next) 128 | priority := nextCost + next.DistanceTo(to) 129 | 130 | // Store the edge and push to the frontier 131 | edges.Store(pNext, encode(nextCost, angle)) 132 | frontier.Push(pNext, priority) 133 | } 134 | }) 135 | } 136 | 137 | return nil, 0, false 138 | } 139 | 140 | // encode packs the cost and direction into a uint32 141 | func encode(cost uint32, dir Direction) uint32 { 142 | return (cost << 4) | uint32(dir&0xF) 143 | } 144 | 145 | // decode unpacks the cost and direction from a uint32 146 | func decode(value uint32) (cost uint32, dir Direction) { 147 | cost = value >> 4 148 | dir = Direction(value & 0xF) 149 | return 150 | } 151 | 152 | // ----------------------------------------------------------------------------- 153 | 154 | type pathfinder struct { 155 | edges *intmap.Map 156 | frontier *frontier 157 | } 158 | 159 | var pathfinders = sync.Pool{ 160 | New: func() any { 161 | return &pathfinder{ 162 | edges: intmap.NewWithFill(32, .99), 163 | frontier: newFrontier(), 164 | } 165 | }, 166 | } 167 | 168 | // Acquires a new instance of a pathfinding state 169 | func acquire(capacity int) *pathfinder { 170 | v := pathfinders.Get().(*pathfinder) 171 | if v.edges.Capacity() < capacity { 172 | v.edges = intmap.NewWithFill(capacity, .99) 173 | } 174 | 175 | return v 176 | } 177 | 178 | // release releases a pathfinding state back to the pool 179 | func release(v *pathfinder) { 180 | v.edges.Clear() 181 | v.frontier.Reset() 182 | pathfinders.Put(v) 183 | } 184 | 185 | // ----------------------------------------------------------------------------- 186 | 187 | // frontier is a priority queue implementation that uses buckets to store 188 | // elements. Original implementation by Iskander Sharipov (https://github.com/quasilyte/pathing) 189 | type frontier struct { 190 | buckets [64][]uint32 191 | mask uint64 192 | } 193 | 194 | // newFrontier creates a new frontier priority queue 195 | func newFrontier() *frontier { 196 | h := &frontier{} 197 | for i := range &h.buckets { 198 | h.buckets[i] = make([]uint32, 0, 16) 199 | } 200 | return h 201 | } 202 | 203 | func (q *frontier) Reset() { 204 | buckets := &q.buckets 205 | 206 | // Reslice storage slices back. 207 | // To avoid traversing all len(q.buckets), 208 | // we have some offset to skip uninteresting (already empty) buckets. 209 | // We also stop when mask is 0 meaning all remaining buckets are empty too. 210 | // In other words, it would only touch slices between min and max non-empty priorities. 211 | mask := q.mask 212 | offset := uint(bits.TrailingZeros64(mask)) 213 | mask >>= offset 214 | i := offset 215 | for mask != 0 { 216 | if i < uint(len(buckets)) { 217 | buckets[i] = buckets[i][:0] 218 | } 219 | mask >>= 1 220 | i++ 221 | } 222 | 223 | q.mask = 0 224 | } 225 | 226 | func (q *frontier) IsEmpty() bool { 227 | return q.mask == 0 228 | } 229 | 230 | func (q *frontier) Push(value, priority uint32) { 231 | // No bound checks since compiler knows that i will never exceed 64. 232 | // We also get a cool truncation of values above 64 to store them 233 | // in our biggest bucket. 234 | i := priority & 0b111111 235 | q.buckets[i] = append(q.buckets[i], value) 236 | q.mask |= 1 << i 237 | } 238 | 239 | func (q *frontier) Pop() uint32 { 240 | buckets := &q.buckets 241 | 242 | // Using uints here and explicit len check to avoid the 243 | // implicitly inserted bound check. 244 | i := uint(bits.TrailingZeros64(q.mask)) 245 | if i < uint(len(buckets)) { 246 | e := buckets[i][len(buckets[i])-1] 247 | buckets[i] = buckets[i][:len(buckets[i])-1] 248 | if len(buckets[i]) == 0 { 249 | q.mask &^= 1 << i 250 | } 251 | return e 252 | } 253 | 254 | // A queue is empty 255 | return 0 256 | } 257 | -------------------------------------------------------------------------------- /path_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "fmt" 8 | "image" 9 | "image/color" 10 | "image/png" 11 | "os" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestPath(t *testing.T) { 19 | m := mapFrom("9x9.png") 20 | path, dist, found := m.Path(At(1, 1), At(7, 7), costOf) 21 | assert.Equal(t, ` 22 | ......... 23 | .x . . 24 | .x ... .. 25 | .xxx . .. 26 | ...x . . 27 | . xxx . 28 | .....x... 29 | . xxx. 30 | .........`, plotPath(m, path)) 31 | 32 | fmt.Println(plotPath(m, path)) 33 | assert.Equal(t, 12, dist) 34 | assert.True(t, found) 35 | } 36 | 37 | func TestPathTiny(t *testing.T) { 38 | m := NewGrid(6, 6) 39 | path, dist, found := m.Path(At(0, 0), At(5, 5), costOf) 40 | assert.Equal(t, ` 41 | x 42 | x 43 | x 44 | x 45 | x 46 | xxxxxx`, plotPath(m, path)) 47 | assert.Equal(t, 10, dist) 48 | assert.True(t, found) 49 | } 50 | 51 | func TestDraw(t *testing.T) { 52 | m := mapFrom("9x9.png") 53 | out := drawGrid(m, NewRect(0, 0, 0, 0)) 54 | assert.NotNil(t, out) 55 | } 56 | 57 | /* 58 | BenchmarkPath/9x9-24 2856020 423.0 ns/op 256 B/op 1 allocs/op 59 | BenchmarkPath/300x300-24 1167 1006143 ns/op 3845 B/op 4 allocs/op 60 | BenchmarkPath/381x381-24 3150 371478 ns/op 12629 B/op 5 allocs/op 61 | BenchmarkPath/384x384-24 3178 374982 ns/op 7298 B/op 5 allocs/op 62 | BenchmarkPath/3069x3069-24 787 1459683 ns/op 106188 B/op 7 allocs/op 63 | BenchmarkPath/3072x3072-24 799 1552230 ns/op 104906 B/op 7 allocs/op 64 | BenchmarkPath/6144x6144-24 3099 381935 ns/op 12716 B/op 5 allocs/op 65 | */ 66 | func BenchmarkPath(b *testing.B) { 67 | b.Run("9x9", func(b *testing.B) { 68 | m := mapFrom("9x9.png") 69 | b.ReportAllocs() 70 | b.ResetTimer() 71 | for n := 0; n < b.N; n++ { 72 | m.Path(At(1, 1), At(7, 7), costOf) 73 | } 74 | }) 75 | 76 | b.Run("300x300", func(b *testing.B) { 77 | m := mapFrom("300x300.png") 78 | b.ReportAllocs() 79 | b.ResetTimer() 80 | for n := 0; n < b.N; n++ { 81 | m.Path(At(115, 20), At(160, 270), costOf) 82 | } 83 | }) 84 | 85 | b.Run("381x381", func(b *testing.B) { 86 | m := NewGrid(381, 381) 87 | b.ReportAllocs() 88 | b.ResetTimer() 89 | for n := 0; n < b.N; n++ { 90 | m.Path(At(0, 0), At(380, 380), costOf) 91 | } 92 | }) 93 | 94 | b.Run("384x384", func(b *testing.B) { 95 | m := NewGrid(384, 384) 96 | b.ReportAllocs() 97 | b.ResetTimer() 98 | for n := 0; n < b.N; n++ { 99 | m.Path(At(0, 0), At(380, 380), costOf) 100 | } 101 | }) 102 | 103 | b.Run("3069x3069", func(b *testing.B) { 104 | m := NewGrid(3069, 3069) 105 | b.ReportAllocs() 106 | b.ResetTimer() 107 | for n := 0; n < b.N; n++ { 108 | m.Path(At(0, 0), At(700, 700), costOf) 109 | } 110 | }) 111 | 112 | b.Run("3072x3072", func(b *testing.B) { 113 | m := NewGrid(3072, 3072) 114 | b.ReportAllocs() 115 | b.ResetTimer() 116 | for n := 0; n < b.N; n++ { 117 | m.Path(At(0, 0), At(700, 700), costOf) 118 | } 119 | }) 120 | 121 | b.Run("6144x6144", func(b *testing.B) { 122 | m := NewGrid(6144, 6144) 123 | b.ReportAllocs() 124 | b.ResetTimer() 125 | for n := 0; n < b.N; n++ { 126 | m.Path(At(0, 0), At(380, 380), costOf) 127 | } 128 | }) 129 | } 130 | 131 | /* 132 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 133 | BenchmarkAround/3r-24 2080566 562.7 ns/op 0 B/op 0 allocs/op 134 | BenchmarkAround/5r-24 885582 1358 ns/op 0 B/op 0 allocs/op 135 | BenchmarkAround/10r-24 300672 3953 ns/op 0 B/op 0 allocs/op 136 | */ 137 | func BenchmarkAround(b *testing.B) { 138 | m := mapFrom("300x300.png") 139 | b.Run("3r", func(b *testing.B) { 140 | b.ReportAllocs() 141 | b.ResetTimer() 142 | for n := 0; n < b.N; n++ { 143 | m.Around(At(115, 20), 3, costOf, func(_ Point, _ Tile[string]) {}) 144 | } 145 | }) 146 | 147 | b.Run("5r", func(b *testing.B) { 148 | b.ReportAllocs() 149 | b.ResetTimer() 150 | for n := 0; n < b.N; n++ { 151 | m.Around(At(115, 20), 5, costOf, func(_ Point, _ Tile[string]) {}) 152 | } 153 | }) 154 | 155 | b.Run("10r", func(b *testing.B) { 156 | b.ReportAllocs() 157 | b.ResetTimer() 158 | for n := 0; n < b.N; n++ { 159 | m.Around(At(115, 20), 10, costOf, func(_ Point, _ Tile[string]) {}) 160 | } 161 | }) 162 | } 163 | 164 | func TestAround(t *testing.T) { 165 | m := mapFrom("9x9.png") 166 | 167 | for i := 0; i < 3; i++ { 168 | var path []string 169 | m.Around(At(2, 2), 3, costOf, func(p Point, tile Tile[string]) { 170 | path = append(path, p.String()) 171 | }) 172 | assert.Equal(t, 10, len(path)) 173 | assert.ElementsMatch(t, []string{ 174 | "2,2", "2,1", "2,3", "1,2", "3,1", 175 | "1,1", "1,3", "3,3", "4,3", "3,4", 176 | }, path) 177 | } 178 | } 179 | 180 | func TestAroundMiss(t *testing.T) { 181 | m := mapFrom("9x9.png") 182 | m.Around(At(20, 20), 3, costOf, func(p Point, tile Tile[string]) { 183 | t.Fail() 184 | }) 185 | } 186 | 187 | /* 188 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 189 | BenchmarkHeap-24 240228 5076 ns/op 6016 B/op 68 allocs/op 190 | */ 191 | func BenchmarkHeap(b *testing.B) { 192 | for i := 0; i < b.N; i++ { 193 | h := newFrontier() 194 | for j := 0; j < 128; j++ { 195 | h.Push(rand(j), 1) 196 | } 197 | for j := 0; j < 128*10; j++ { 198 | h.Push(rand(j), 1) 199 | h.Pop() 200 | } 201 | } 202 | } 203 | 204 | // very fast semi-random function 205 | func rand(i int) uint32 { 206 | i = i + 10000 207 | i = i ^ (i << 16) 208 | i = (i >> 5) ^ i 209 | return uint32(i & 0xFF) 210 | } 211 | 212 | // ----------------------------------------------------------------------------- 213 | 214 | // Cost estimation function 215 | func costOf(tile Value) uint16 { 216 | if (tile)&1 != 0 { 217 | return 0 // Blocked 218 | } 219 | return 1 220 | } 221 | 222 | // mapFrom creates a map from ASCII string 223 | func mapFrom(name string) *Grid[string] { 224 | f, err := os.Open("fixtures/" + name) 225 | defer f.Close() 226 | if err != nil { 227 | panic(err) 228 | } 229 | 230 | // Decode the image 231 | img, err := png.Decode(f) 232 | if err != nil { 233 | panic(err) 234 | } 235 | 236 | m := NewGrid(int16(img.Bounds().Dx()), int16(img.Bounds().Dy())) 237 | for y := int16(0); y < m.Size.Y; y++ { 238 | for x := int16(0); x < m.Size.X; x++ { 239 | //fmt.Printf("%+v %T\n", img.At(int(x), int(y)), img.At(int(x), int(y))) 240 | v := img.At(int(x), int(y)).(color.RGBA) 241 | switch v.R { 242 | case 255: 243 | case 0: 244 | m.WriteAt(x, y, Value(0xff)) 245 | } 246 | 247 | } 248 | } 249 | return m 250 | } 251 | 252 | // plotPath plots the path on ASCII map 253 | func plotPath(m *Grid[string], path []Point) string { 254 | out := make([][]byte, m.Size.Y) 255 | for i := range out { 256 | out[i] = make([]byte, m.Size.X) 257 | } 258 | 259 | m.Each(func(l Point, tile Tile[string]) { 260 | //println(l.String(), int(tile[0])) 261 | switch { 262 | case pointInPath(l, path): 263 | out[l.Y][l.X] = 'x' 264 | case tile.Value()&1 != 0: 265 | out[l.Y][l.X] = '.' 266 | default: 267 | out[l.Y][l.X] = ' ' 268 | } 269 | }) 270 | 271 | var sb strings.Builder 272 | for _, line := range out { 273 | sb.WriteByte('\n') 274 | sb.WriteString(string(line)) 275 | } 276 | return sb.String() 277 | } 278 | 279 | // pointInPath returns whether a point is part of a path or not 280 | func pointInPath(point Point, path []Point) bool { 281 | for _, p := range path { 282 | if p.Equal(point) { 283 | return true 284 | } 285 | } 286 | return false 287 | } 288 | 289 | // draw converts the map to a black and white image for debugging purposes. 290 | func drawGrid(m *Grid[string], rect Rect) image.Image { 291 | if rect.Max.X == 0 || rect.Max.Y == 0 { 292 | rect = NewRect(0, 0, m.Size.X, m.Size.Y) 293 | } 294 | 295 | size := rect.Size() 296 | output := image.NewRGBA(image.Rect(0, 0, int(size.X), int(size.Y))) 297 | m.Within(rect.Min, rect.Max, func(p Point, tile Tile[string]) { 298 | a := uint8(255) 299 | if tile.Value() == 1 { 300 | a = 0 301 | } 302 | 303 | output.SetRGBA(int(p.X), int(p.Y), color.RGBA{a, a, a, 255}) 304 | }) 305 | return output 306 | } 307 | -------------------------------------------------------------------------------- /point.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | const invalid = int16(-1 << 15) 11 | 12 | // ----------------------------------------------------------------------------- 13 | 14 | // Point represents a 2D coordinate. 15 | type Point struct { 16 | X int16 // X coordinate 17 | Y int16 // Y coordinate 18 | } 19 | 20 | func unpackPoint(v uint32) Point { 21 | return At(int16(v>>16), int16(v)) 22 | } 23 | 24 | // At creates a new point at a specified x,y coordinate. 25 | func At(x, y int16) Point { 26 | return Point{X: x, Y: y} 27 | } 28 | 29 | // String returns string representation of a point. 30 | func (p Point) String() string { 31 | return fmt.Sprintf("%v,%v", p.X, p.Y) 32 | } 33 | 34 | // Integer returns a packed 32-bit integer representation of a point. 35 | func (p Point) Integer() uint32 { 36 | return (uint32(p.X) << 16) | (uint32(p.Y) & 0xffff) 37 | } 38 | 39 | // Equal compares two points and returns true if they are equal. 40 | func (p Point) Equal(other Point) bool { 41 | return p.X == other.X && p.Y == other.Y 42 | } 43 | 44 | // Add adds two points together. 45 | func (p Point) Add(p2 Point) Point { 46 | return Point{p.X + p2.X, p.Y + p2.Y} 47 | } 48 | 49 | // Subtract subtracts the second point from the first. 50 | func (p Point) Subtract(p2 Point) Point { 51 | return Point{p.X - p2.X, p.Y - p2.Y} 52 | } 53 | 54 | // Multiply multiplies two points together. 55 | func (p Point) Multiply(p2 Point) Point { 56 | return Point{p.X * p2.X, p.Y * p2.Y} 57 | } 58 | 59 | // Divide divides the first point by the second. 60 | func (p Point) Divide(p2 Point) Point { 61 | return Point{p.X / p2.X, p.Y / p2.Y} 62 | } 63 | 64 | // MultiplyScalar multiplies the given point by the scalar. 65 | func (p Point) MultiplyScalar(s int16) Point { 66 | return Point{p.X * s, p.Y * s} 67 | } 68 | 69 | // DivideScalar divides the given point by the scalar. 70 | func (p Point) DivideScalar(s int16) Point { 71 | return Point{p.X / s, p.Y / s} 72 | } 73 | 74 | // Within checks if the point is within the specified bounding box. 75 | func (p Point) Within(nw, se Point) bool { 76 | return Rect{Min: nw, Max: se}.Contains(p) 77 | } 78 | 79 | // WithinRect checks if the point is within the specified bounding box. 80 | func (p Point) WithinRect(box Rect) bool { 81 | return box.Contains(p) 82 | } 83 | 84 | // WithinSize checks if the point is within the specified bounding box 85 | // which starts at 0,0 until the width/height provided. 86 | func (p Point) WithinSize(size Point) bool { 87 | return p.X >= 0 && p.Y >= 0 && p.X < size.X && p.Y < size.Y 88 | } 89 | 90 | // Move moves a point by one in the specified direction. 91 | func (p Point) Move(direction Direction) Point { 92 | return p.MoveBy(direction, 1) 93 | } 94 | 95 | // MoveBy moves a point by n in the specified direction. 96 | func (p Point) MoveBy(direction Direction, n int16) Point { 97 | switch direction { 98 | case North: 99 | return Point{p.X, p.Y - n} 100 | case NorthEast: 101 | return Point{p.X + n, p.Y - n} 102 | case East: 103 | return Point{p.X + n, p.Y} 104 | case SouthEast: 105 | return Point{p.X + n, p.Y + n} 106 | case South: 107 | return Point{p.X, p.Y + n} 108 | case SouthWest: 109 | return Point{p.X - n, p.Y + n} 110 | case West: 111 | return Point{p.X - n, p.Y} 112 | case NorthWest: 113 | return Point{p.X - n, p.Y - n} 114 | default: 115 | return p 116 | } 117 | } 118 | 119 | // DistanceTo calculates manhattan distance to the other point 120 | func (p Point) DistanceTo(other Point) uint32 { 121 | return abs(int32(p.X)-int32(other.X)) + abs(int32(p.Y)-int32(other.Y)) 122 | } 123 | 124 | func abs(n int32) uint32 { 125 | if n < 0 { 126 | return uint32(-n) 127 | } 128 | return uint32(n) 129 | } 130 | 131 | // ----------------------------------------------------------------------------- 132 | 133 | // Rect represents a rectangle 134 | type Rect struct { 135 | Min Point // Top left point of the rectangle 136 | Max Point // Bottom right point of the rectangle 137 | } 138 | 139 | // NewRect creates a new rectangle 140 | // left,top,right,bottom correspond to x1,y1,x2,y2 141 | func NewRect(left, top, right, bottom int16) Rect { 142 | return Rect{Min: At(left, top), Max: At(right, bottom)} 143 | } 144 | 145 | // Contains returns whether a point is within the rectangle or not. 146 | func (a Rect) Contains(p Point) bool { 147 | return a.Min.X <= p.X && p.X < a.Max.X && a.Min.Y <= p.Y && p.Y < a.Max.Y 148 | } 149 | 150 | // Intersects returns whether a rectangle intersects with another rectangle or not. 151 | func (a Rect) Intersects(b Rect) bool { 152 | return b.Min.X < a.Max.X && a.Min.X < b.Max.X && b.Min.Y < a.Max.Y && a.Min.Y < b.Max.Y 153 | } 154 | 155 | // Size returns the size of the rectangle 156 | func (a *Rect) Size() Point { 157 | return Point{ 158 | X: a.Max.X - a.Min.X, 159 | Y: a.Max.Y - a.Min.Y, 160 | } 161 | } 162 | 163 | // IsZero returns true if the rectangle is zero-value 164 | func (a Rect) IsZero() bool { 165 | return a.Min.X == a.Max.X && a.Min.Y == a.Max.Y 166 | } 167 | 168 | // Difference calculates up to four non-overlapping regions in a that are not covered by b. 169 | // If there are fewer than four distinct regions, the remaining Rects will be zero-value. 170 | func (a Rect) Difference(b Rect) (result [4]Rect) { 171 | if b.Contains(a.Min) && b.Contains(a.Max) { 172 | return // Fully covered, return zero-value result 173 | } 174 | 175 | // Check for non-overlapping cases 176 | if !a.Intersects(b) { 177 | result[0] = a // No overlap, return A as is 178 | return 179 | } 180 | 181 | left := min(a.Min.X, b.Min.X) 182 | right := max(a.Max.X, b.Max.X) 183 | top := min(a.Min.Y, b.Min.Y) 184 | bottom := max(a.Max.Y, b.Max.Y) 185 | 186 | result[0].Min = Point{X: left, Y: top} 187 | result[0].Max = Point{X: right, Y: max(a.Min.Y, b.Min.Y)} 188 | 189 | result[1].Min = Point{X: left, Y: min(a.Max.Y, b.Max.Y)} 190 | result[1].Max = Point{X: right, Y: bottom} 191 | 192 | result[2].Min = Point{X: left, Y: top} 193 | result[2].Max = Point{X: max(a.Min.X, b.Min.X), Y: bottom} 194 | 195 | result[3].Min = Point{X: min(a.Max.X, b.Max.X), Y: top} 196 | result[3].Max = Point{X: right, Y: bottom} 197 | 198 | if result[0].Size().X == 0 || result[0].Size().Y == 0 { 199 | result[0] = Rect{} 200 | } 201 | if result[1].Size().X == 0 || result[1].Size().Y == 0 { 202 | result[1] = Rect{} 203 | } 204 | if result[2].Size().X == 0 || result[2].Size().Y == 0 { 205 | result[2] = Rect{} 206 | } 207 | if result[3].Size().X == 0 || result[3].Size().Y == 0 { 208 | result[3] = Rect{} 209 | } 210 | 211 | return 212 | } 213 | 214 | // Pack returns a packed representation of a rectangle 215 | func (a Rect) pack() uint64 { 216 | return uint64(a.Min.Integer())<<32 | uint64(a.Max.Integer()) 217 | } 218 | 219 | // Unpack returns a rectangle from a packed representation 220 | func unpackRect(v uint64) Rect { 221 | return Rect{ 222 | Min: unpackPoint(uint32(v >> 32)), 223 | Max: unpackPoint(uint32(v)), 224 | } 225 | } 226 | 227 | // ----------------------------------------------------------------------------- 228 | 229 | // Diretion represents a direction 230 | type Direction byte 231 | 232 | // Various directions 233 | const ( 234 | North Direction = iota 235 | NorthEast 236 | East 237 | SouthEast 238 | South 239 | SouthWest 240 | West 241 | NorthWest 242 | ) 243 | 244 | // String returns a string representation of a direction 245 | func (v Direction) String() string { 246 | switch v { 247 | case North: 248 | return "🡱N" 249 | case NorthEast: 250 | return "🡵NE" 251 | case East: 252 | return "🡲E" 253 | case SouthEast: 254 | return "🡶SE" 255 | case South: 256 | return "🡳S" 257 | case SouthWest: 258 | return "🡷SW" 259 | case West: 260 | return "🡰W" 261 | case NorthWest: 262 | return "🡴NW" 263 | default: 264 | return "" 265 | } 266 | } 267 | 268 | // Vector returns a direction vector with a given scale 269 | func (v Direction) Vector(scale int16) Point { 270 | return Point{}.MoveBy(v, scale) 271 | } 272 | 273 | // angleOf returns the direction from one point to another 274 | func angleOf(from, to Point) Direction { 275 | dx := to.X - from.X 276 | dy := to.Y - from.Y 277 | 278 | switch { 279 | case dx == 0 && dy == -1: 280 | return North 281 | case dx == 1 && dy == -1: 282 | return NorthEast 283 | case dx == 1 && dy == 0: 284 | return East 285 | case dx == 1 && dy == 1: 286 | return SouthEast 287 | case dx == 0 && dy == 1: 288 | return South 289 | case dx == -1 && dy == 1: 290 | return SouthWest 291 | case dx == -1 && dy == 0: 292 | return West 293 | case dx == -1 && dy == -1: 294 | return NorthWest 295 | default: 296 | return Direction(0) // Invalid direction 297 | } 298 | } 299 | 300 | // oppositeDirection returns the opposite of the given direction 301 | func oppositeDirection(dir Direction) Direction { 302 | return Direction((dir + 4) % 8) 303 | } 304 | -------------------------------------------------------------------------------- /point_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | /* 13 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 14 | BenchmarkPoint/within-24 1000000000 0.09854 ns/op 0 B/op 0 allocs/op 15 | BenchmarkPoint/within-rect-24 1000000000 0.09966 ns/op 0 B/op 0 allocs/op 16 | */ 17 | func BenchmarkPoint(b *testing.B) { 18 | p := At(10, 20) 19 | b.Run("within", func(b *testing.B) { 20 | b.ReportAllocs() 21 | b.ResetTimer() 22 | for n := 0; n < b.N; n++ { 23 | p.Within(At(0, 0), At(100, 100)) 24 | } 25 | }) 26 | 27 | b.Run("within-rect", func(b *testing.B) { 28 | b.ReportAllocs() 29 | b.ResetTimer() 30 | for n := 0; n < b.N; n++ { 31 | p.WithinRect(NewRect(0, 0, 100, 100)) 32 | } 33 | }) 34 | } 35 | 36 | func TestPoint(t *testing.T) { 37 | p := At(10, 20) 38 | p2 := At(2, 2) 39 | 40 | assert.Equal(t, int16(10), p.X) 41 | assert.Equal(t, int16(20), p.Y) 42 | assert.Equal(t, uint32(0xa0014), p.Integer()) 43 | assert.Equal(t, At(-5, 5), unpackPoint(At(-5, 5).Integer())) 44 | assert.Equal(t, "10,20", p.String()) 45 | assert.True(t, p.Equal(At(10, 20))) 46 | assert.Equal(t, "20,40", p.MultiplyScalar(2).String()) 47 | assert.Equal(t, "5,10", p.DivideScalar(2).String()) 48 | assert.Equal(t, "12,22", p.Add(p2).String()) 49 | assert.Equal(t, "8,18", p.Subtract(p2).String()) 50 | assert.Equal(t, "20,40", p.Multiply(p2).String()) 51 | assert.Equal(t, "5,10", p.Divide(p2).String()) 52 | assert.True(t, p.Within(At(1, 1), At(20, 30))) 53 | assert.True(t, p.WithinRect(NewRect(1, 1, 20, 30))) 54 | assert.False(t, p.WithinSize(At(10, 20))) 55 | assert.True(t, p.WithinSize(At(20, 30))) 56 | } 57 | 58 | func TestIntersects(t *testing.T) { 59 | assert.True(t, NewRect(0, 0, 2, 2).Intersects(NewRect(1, 0, 3, 2))) 60 | assert.False(t, NewRect(0, 0, 2, 2).Intersects(NewRect(2, 0, 4, 2))) 61 | assert.False(t, NewRect(10, 10, 12, 12).Intersects(NewRect(9, 12, 11, 14))) 62 | } 63 | 64 | func TestDirection(t *testing.T) { 65 | for i := 0; i < 8; i++ { 66 | dir := Direction(i) 67 | assert.NotEmpty(t, dir.String()) 68 | } 69 | } 70 | 71 | func TestDirection_Empty(t *testing.T) { 72 | dir := Direction(9) 73 | assert.Empty(t, dir.String()) 74 | } 75 | 76 | func TestMove(t *testing.T) { 77 | tests := []struct { 78 | dir Direction 79 | out Point 80 | }{ 81 | {North, Point{X: 0, Y: -1}}, 82 | {South, Point{X: 0, Y: 1}}, 83 | {East, Point{X: 1, Y: 0}}, 84 | {West, Point{X: -1, Y: 0}}, 85 | {NorthEast, Point{X: 1, Y: -1}}, 86 | {NorthWest, Point{X: -1, Y: -1}}, 87 | {SouthEast, Point{X: 1, Y: 1}}, 88 | {SouthWest, Point{X: -1, Y: 1}}, 89 | {Direction(99), Point{}}, 90 | } 91 | 92 | for _, tc := range tests { 93 | assert.Equal(t, tc.out, Point{}.Move(tc.dir), tc.dir.String()) 94 | } 95 | } 96 | 97 | func TestContains(t *testing.T) { 98 | tests := map[Point]bool{ 99 | {X: 0, Y: 0}: true, 100 | {X: 1, Y: 0}: true, 101 | {X: 0, Y: 1}: true, 102 | {X: 1, Y: 1}: true, 103 | {X: 2, Y: 2}: false, 104 | {X: 3, Y: 3}: false, 105 | {X: 1, Y: 2}: false, 106 | {X: 2, Y: 1}: false, 107 | } 108 | 109 | for point, expect := range tests { 110 | r := NewRect(0, 0, 2, 2) 111 | assert.Equal(t, expect, r.Contains(point), point.String()) 112 | } 113 | } 114 | 115 | func TestDiff_Right(t *testing.T) { 116 | a := Rect{At(0, 0), At(2, 2)} 117 | b := Rect{At(1, 0), At(3, 2)} 118 | 119 | diff := a.Difference(b) 120 | assert.Equal(t, Rect{At(0, 0), At(1, 2)}, diff[2]) 121 | assert.Equal(t, Rect{At(2, 0), At(3, 2)}, diff[3]) 122 | } 123 | 124 | func TestDiff_Left(t *testing.T) { 125 | a := Rect{At(0, 0), At(2, 2)} 126 | b := Rect{At(-1, 0), At(1, 2)} 127 | 128 | diff := a.Difference(b) 129 | assert.Equal(t, Rect{At(-1, 0), At(0, 2)}, diff[2]) 130 | assert.Equal(t, Rect{At(1, 0), At(2, 2)}, diff[3]) 131 | } 132 | 133 | func TestDiff_Up(t *testing.T) { 134 | a := Rect{At(0, 0), At(2, 2)} 135 | b := Rect{At(0, -1), At(2, 1)} 136 | 137 | diff := a.Difference(b) 138 | assert.Equal(t, Rect{At(0, -1), At(2, 0)}, diff[0]) 139 | assert.Equal(t, Rect{At(0, 1), At(2, 2)}, diff[1]) 140 | } 141 | 142 | func TestDiff_Down(t *testing.T) { 143 | a := Rect{At(0, 0), At(2, 2)} 144 | b := Rect{At(0, 1), At(2, 3)} 145 | 146 | diff := a.Difference(b) 147 | assert.Equal(t, Rect{At(0, 0), At(2, 1)}, diff[0]) 148 | assert.Equal(t, Rect{At(0, 2), At(2, 3)}, diff[1]) 149 | } 150 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "compress/flate" 8 | "encoding/binary" 9 | "io" 10 | "os" 11 | "unsafe" 12 | 13 | "github.com/kelindar/iostream" 14 | ) 15 | 16 | const tileDataSize = int(unsafe.Sizeof([9]Value{})) 17 | 18 | // ---------------------------------- Stream ---------------------------------- 19 | 20 | // WriteTo writes the grid to a specific writer. 21 | func (m *Grid[T]) WriteTo(dst io.Writer) (n int64, err error) { 22 | p1 := At(0, 0) 23 | p2 := At(m.Size.X-1, m.Size.Y-1) 24 | 25 | // Write the viewport size 26 | w := iostream.NewWriter(dst) 27 | header := make([]byte, 8) 28 | binary.BigEndian.PutUint16(header[0:2], uint16(p1.X)) 29 | binary.BigEndian.PutUint16(header[2:4], uint16(p1.Y)) 30 | binary.BigEndian.PutUint16(header[4:6], uint16(p2.X)) 31 | binary.BigEndian.PutUint16(header[6:8], uint16(p2.Y)) 32 | if _, err := w.Write(header); err != nil { 33 | return w.Offset(), err 34 | } 35 | 36 | // Write the grid data 37 | m.pagesWithin(p1, p2, func(page *page[T]) { 38 | buffer := (*[tileDataSize]byte)(unsafe.Pointer(&page.tiles))[:] 39 | if _, err := w.Write(buffer); err != nil { 40 | return 41 | } 42 | }) 43 | return w.Offset(), nil 44 | } 45 | 46 | // ReadFrom reads the grid from the reader. 47 | func ReadFrom[T comparable](src io.Reader) (grid *Grid[T], err error) { 48 | r := iostream.NewReader(src) 49 | header := make([]byte, 8) 50 | if _, err := io.ReadFull(r, header); err != nil { 51 | return nil, err 52 | } 53 | 54 | // Read the size 55 | var view Rect 56 | view.Min.X = int16(binary.BigEndian.Uint16(header[0:2])) 57 | view.Min.Y = int16(binary.BigEndian.Uint16(header[2:4])) 58 | view.Max.X = int16(binary.BigEndian.Uint16(header[4:6])) 59 | view.Max.Y = int16(binary.BigEndian.Uint16(header[6:8])) 60 | 61 | // Allocate a new grid 62 | grid = NewGridOf[T](view.Max.X+1, view.Max.Y+1) 63 | buf := make([]byte, tileDataSize) 64 | grid.pagesWithin(view.Min, view.Max, func(page *page[T]) { 65 | if _, err = io.ReadFull(r, buf); err != nil { 66 | return 67 | } 68 | 69 | copy((*[tileDataSize]byte)(unsafe.Pointer(&page.tiles))[:], buf) 70 | }) 71 | return 72 | } 73 | 74 | // ---------------------------------- File ---------------------------------- 75 | 76 | // WriteFile writes the grid into a flate-compressed binary file. 77 | func (m *Grid[T]) WriteFile(filename string) error { 78 | file, err := os.Create(filename) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | defer file.Close() 84 | writer, err := flate.NewWriter(file, flate.BestSpeed) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // WriteTo the underlying writer 90 | defer writer.Close() 91 | _, err = m.WriteTo(writer) 92 | return err 93 | } 94 | 95 | // Restore restores the grid from the specified file. The grid must 96 | // be written using the corresponding WriteFile() method. 97 | func ReadFile[T comparable](filename string) (grid *Grid[T], err error) { 98 | if _, err := os.Stat(filename); os.IsNotExist(err) { 99 | return nil, os.ErrNotExist 100 | } 101 | 102 | // Otherwise, attempt to open the file and restore 103 | file, err := os.Open(filename) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | defer file.Close() 109 | return ReadFrom[T](flate.NewReader(file)) 110 | } 111 | -------------------------------------------------------------------------------- /store_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "bytes" 8 | "compress/flate" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | /* 16 | cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz 17 | BenchmarkStore/save-8 14455 81883 ns/op 8 B/op 1 allocs/op 18 | BenchmarkStore/read-8 2787 399699 ns/op 647421 B/op 7 allocs/op 19 | */ 20 | func BenchmarkStore(b *testing.B) { 21 | m := mapFrom("300x300.png") 22 | 23 | b.Run("save", func(b *testing.B) { 24 | out := bytes.NewBuffer(make([]byte, 0, 550000)) 25 | 26 | b.ReportAllocs() 27 | b.ResetTimer() 28 | for n := 0; n < b.N; n++ { 29 | out.Reset() 30 | m.WriteTo(out) 31 | } 32 | }) 33 | 34 | b.Run("read", func(b *testing.B) { 35 | enc := new(bytes.Buffer) 36 | m.WriteTo(enc) 37 | 38 | b.ReportAllocs() 39 | b.ResetTimer() 40 | for n := 0; n < b.N; n++ { 41 | ReadFrom[string](bytes.NewBuffer(enc.Bytes())) 42 | } 43 | }) 44 | 45 | } 46 | 47 | func TestSaveLoad(t *testing.T) { 48 | m := mapFrom("300x300.png") 49 | 50 | // Save the map 51 | enc := new(bytes.Buffer) 52 | n, err := m.WriteTo(enc) 53 | assert.NoError(t, err) 54 | assert.Equal(t, int64(360008), n) 55 | 56 | // Load the map back 57 | out, err := ReadFrom[string](enc) 58 | assert.NoError(t, err) 59 | assert.Equal(t, m.pages, out.pages) 60 | } 61 | 62 | func TestSaveLoadFlate(t *testing.T) { 63 | m := mapFrom("300x300.png") 64 | 65 | // Save the map 66 | output := new(bytes.Buffer) 67 | writer, err := flate.NewWriter(output, flate.BestSpeed) 68 | assert.NoError(t, err) 69 | 70 | n, err := m.WriteTo(writer) 71 | assert.NoError(t, writer.Close()) 72 | assert.NoError(t, err) 73 | assert.Equal(t, int64(360008), n) 74 | assert.Equal(t, int(16533), output.Len()) 75 | 76 | // Load the map back 77 | reader := flate.NewReader(output) 78 | out, err := ReadFrom[string](reader) 79 | assert.NoError(t, err) 80 | assert.Equal(t, m.pages, out.pages) 81 | } 82 | 83 | func TestSaveLoadFile(t *testing.T) { 84 | temp, err := os.CreateTemp("", "*") 85 | assert.NoError(t, err) 86 | defer os.Remove(temp.Name()) 87 | 88 | // Write a test map into temp file 89 | m := mapFrom("300x300.png") 90 | assert.NoError(t, m.WriteFile(temp.Name())) 91 | 92 | fi, _ := temp.Stat() 93 | assert.Equal(t, int64(16533), fi.Size()) 94 | 95 | // Read the map back 96 | out, err := ReadFile[string](temp.Name()) 97 | assert.NoError(t, err) 98 | assert.Equal(t, m.pages, out.pages) 99 | } 100 | -------------------------------------------------------------------------------- /view.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "sync" 8 | "sync/atomic" 9 | ) 10 | 11 | // Observer represents a tile update Observer. 12 | type Observer[T comparable] interface { 13 | Viewport() Rect 14 | Resize(Rect, func(Point, Tile[T])) 15 | onUpdate(*Update[T]) 16 | } 17 | 18 | // ValueAt represents a tile and its value. 19 | type ValueAt struct { 20 | Point // The point of the tile 21 | Value // The value of the tile 22 | } 23 | 24 | // Update represents a tile update notification. 25 | type Update[T comparable] struct { 26 | Old ValueAt // Old tile + value 27 | New ValueAt // New tile + value 28 | Add T // An object was added to the tile 29 | Del T // An object was removed from the tile 30 | } 31 | 32 | var _ Observer[string] = (*View[string, string])(nil) 33 | 34 | // View represents a view which can monitor a collection of tiles. Type parameters 35 | // S and T are the state and tile types respectively. 36 | type View[S any, T comparable] struct { 37 | Grid *Grid[T] // The associated map 38 | Inbox chan Update[T] // The update inbox for the view 39 | State S // The state of the view 40 | rect atomic.Uint64 // The view box 41 | } 42 | 43 | // NewView creates a new view for a map with a given state. State can be anything 44 | // that is passed to the view and can be used to store additional information. 45 | func NewView[S any, T comparable](m *Grid[T], state S) *View[S, T] { 46 | v := &View[S, T]{ 47 | Grid: m, 48 | Inbox: make(chan Update[T], 32), 49 | State: state, 50 | } 51 | v.rect.Store(NewRect(-1, -1, -1, -1).pack()) 52 | return v 53 | } 54 | 55 | // Viewport returns the current viewport of the view. 56 | func (v *View[S, T]) Viewport() Rect { 57 | return unpackRect(v.rect.Load()) 58 | } 59 | 60 | // Resize resizes the viewport and notifies the observers of the changes. 61 | func (v *View[S, T]) Resize(view Rect, fn func(Point, Tile[T])) { 62 | grid := v.Grid 63 | prev := unpackRect(v.rect.Swap(view.pack())) 64 | 65 | for _, diff := range view.Difference(prev) { 66 | if diff.IsZero() { 67 | continue // Skip zero-value rectangles 68 | } 69 | 70 | grid.pagesWithin(diff.Min, diff.Max, func(page *page[T]) { 71 | r := page.Bounds() 72 | switch { 73 | 74 | // Page is now in view 75 | case view.Intersects(r) && !prev.Intersects(r): 76 | if grid.observers.Subscribe(page.point, v) { 77 | page.SetObserved(true) // Mark the page as being observed 78 | } 79 | 80 | // Page is no longer in view 81 | case !view.Intersects(r) && prev.Intersects(r): 82 | if grid.observers.Unsubscribe(page.point, v) { 83 | page.SetObserved(false) // Mark the page as not being observed 84 | } 85 | } 86 | 87 | // Callback for each new tile in the view 88 | if fn != nil { 89 | page.Each(v.Grid, func(p Point, tile Tile[T]) { 90 | if view.Contains(p) && !prev.Contains(p) { 91 | fn(p, tile) 92 | } 93 | }) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | // MoveTo moves the viewport towards a particular direction. 100 | func (v *View[S, T]) MoveTo(angle Direction, distance int16, fn func(Point, Tile[T])) { 101 | p := angle.Vector(distance) 102 | r := v.Viewport() 103 | v.Resize(Rect{ 104 | Min: r.Min.Add(p), 105 | Max: r.Max.Add(p), 106 | }, fn) 107 | } 108 | 109 | // MoveBy moves the viewport towards a particular direction. 110 | func (v *View[S, T]) MoveBy(x, y int16, fn func(Point, Tile[T])) { 111 | r := v.Viewport() 112 | v.Resize(Rect{ 113 | Min: r.Min.Add(At(x, y)), 114 | Max: r.Max.Add(At(x, y)), 115 | }, fn) 116 | } 117 | 118 | // MoveAt moves the viewport to a specific coordinate. 119 | func (v *View[S, T]) MoveAt(nw Point, fn func(Point, Tile[T])) { 120 | r := v.Viewport() 121 | size := r.Max.Subtract(r.Min) 122 | v.Resize(Rect{ 123 | Min: nw, 124 | Max: nw.Add(size), 125 | }, fn) 126 | } 127 | 128 | // Each iterates over all of the tiles in the view. 129 | func (v *View[S, T]) Each(fn func(Point, Tile[T])) { 130 | r := v.Viewport() 131 | v.Grid.Within(r.Min, r.Max, fn) 132 | } 133 | 134 | // At returns the tile at a specified position. 135 | func (v *View[S, T]) At(x, y int16) (Tile[T], bool) { 136 | return v.Grid.At(x, y) 137 | } 138 | 139 | // WriteAt updates the entire tile at a specific coordinate. 140 | func (v *View[S, T]) WriteAt(x, y int16, tile Value) { 141 | v.Grid.WriteAt(x, y, tile) 142 | } 143 | 144 | // MergeAt updates the bits of tile at a specific coordinate. The bits are specified 145 | // by the mask. The bits that need to be updated should be flipped on in the mask. 146 | func (v *View[S, T]) MergeAt(x, y int16, tile, mask Value) { 147 | v.Grid.MaskAt(x, y, tile, mask) 148 | } 149 | 150 | // Close closes the view and unsubscribes from everything. 151 | func (v *View[S, T]) Close() error { 152 | r := v.Viewport() 153 | v.Grid.pagesWithin(r.Min, r.Max, func(page *page[T]) { 154 | if v.Grid.observers.Unsubscribe(page.point, v) { 155 | page.SetObserved(false) // Mark the page as not being observed 156 | } 157 | }) 158 | return nil 159 | } 160 | 161 | // onUpdate occurs when a tile has updated. 162 | func (v *View[S, T]) onUpdate(ev *Update[T]) { 163 | v.Inbox <- *ev // (copy) 164 | } 165 | 166 | // ----------------------------------------------------------------------------- 167 | 168 | // Pubsub represents a publish/subscribe layer for observers. 169 | type pubsub[T comparable] struct { 170 | m sync.Map // Concurrent map of observers 171 | tmp sync.Pool // Temporary observer sets for notifications 172 | } 173 | 174 | // Subscribe registers an event listener on a system 175 | func (p *pubsub[T]) Subscribe(page Point, sub Observer[T]) bool { 176 | if v, ok := p.m.Load(page.Integer()); ok { 177 | return v.(*observers[T]).Subscribe(sub) 178 | } 179 | 180 | // Slow path 181 | v, _ := p.m.LoadOrStore(page.Integer(), newObservers[T]()) 182 | return v.(*observers[T]).Subscribe(sub) 183 | } 184 | 185 | // Unsubscribe deregisters an event listener from a system 186 | func (p *pubsub[T]) Unsubscribe(page Point, sub Observer[T]) bool { 187 | if v, ok := p.m.Load(page.Integer()); ok { 188 | return v.(*observers[T]).Unsubscribe(sub) 189 | } 190 | return false 191 | } 192 | 193 | // Notify notifies listeners of an update that happened. 194 | func (p *pubsub[T]) Notify1(ev *Update[T], page Point) { 195 | p.Each1(func(sub Observer[T]) { 196 | viewport := sub.Viewport() 197 | if viewport.Contains(ev.New.Point) || viewport.Contains(ev.Old.Point) { 198 | sub.onUpdate(ev) 199 | } 200 | }, page) 201 | } 202 | 203 | // Notify notifies listeners of an update that happened. 204 | func (p *pubsub[T]) Notify2(ev *Update[T], pages [2]Point) { 205 | p.Each2(func(sub Observer[T]) { 206 | viewport := sub.Viewport() 207 | if viewport.Contains(ev.New.Point) || viewport.Contains(ev.Old.Point) { 208 | sub.onUpdate(ev) 209 | } 210 | }, pages) 211 | } 212 | 213 | // Each iterates over each observer in a page 214 | func (p *pubsub[T]) Each1(fn func(sub Observer[T]), page Point) { 215 | if v, ok := p.m.Load(page.Integer()); ok { 216 | v.(*observers[T]).Each(func(sub Observer[T]) { 217 | fn(sub) 218 | }) 219 | } 220 | } 221 | 222 | // Each2 iterates over each observer in a page 223 | func (p *pubsub[T]) Each2(fn func(sub Observer[T]), pages [2]Point) { 224 | targets := p.tmp.Get().(map[Observer[T]]struct{}) 225 | clear(targets) 226 | defer p.tmp.Put(targets) 227 | 228 | // Collect all observers from all pages 229 | for _, page := range pages { 230 | if v, ok := p.m.Load(page.Integer()); ok { 231 | v.(*observers[T]).Each(func(sub Observer[T]) { 232 | targets[sub] = struct{}{} 233 | }) 234 | } 235 | } 236 | 237 | // Invoke the callback for each observer, once 238 | for sub := range targets { 239 | fn(sub) 240 | } 241 | } 242 | 243 | // ----------------------------------------------------------------------------- 244 | 245 | // Observers represents a change notifier which notifies the subscribers when 246 | // a specific tile is updated. 247 | type observers[T comparable] struct { 248 | sync.Mutex 249 | subs []Observer[T] 250 | } 251 | 252 | // newObservers creates a new instance of an change observer. 253 | func newObservers[T comparable]() *observers[T] { 254 | return &observers[T]{ 255 | subs: make([]Observer[T], 0, 8), 256 | } 257 | } 258 | 259 | // Each iterates over each observer 260 | func (s *observers[T]) Each(fn func(sub Observer[T])) { 261 | if s == nil { 262 | return 263 | } 264 | 265 | s.Lock() 266 | defer s.Unlock() 267 | for _, sub := range s.subs { 268 | fn(sub) 269 | } 270 | } 271 | 272 | // Subscribe registers an event listener on a system 273 | func (s *observers[T]) Subscribe(sub Observer[T]) bool { 274 | s.Lock() 275 | defer s.Unlock() 276 | s.subs = append(s.subs, sub) 277 | return len(s.subs) > 0 // At least one 278 | } 279 | 280 | // Unsubscribe deregisters an event listener from a system 281 | func (s *observers[T]) Unsubscribe(sub Observer[T]) bool { 282 | s.Lock() 283 | defer s.Unlock() 284 | 285 | clean := s.subs[:0] 286 | for _, o := range s.subs { 287 | if o != sub { 288 | clean = append(clean, o) 289 | } 290 | } 291 | s.subs = clean 292 | return len(s.subs) == 0 293 | } 294 | -------------------------------------------------------------------------------- /view_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Roman Atachiants and contributors. All rights reserved. 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. 3 | 4 | package tile 5 | 6 | import ( 7 | "testing" 8 | "unsafe" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | /* 14 | cpu: 13th Gen Intel(R) Core(TM) i7-13700K 15 | BenchmarkView/write-24 9540012 125.0 ns/op 48 B/op 1 allocs/op 16 | BenchmarkView/move-24 16141 74408 ns/op 0 B/op 0 allocs/op 17 | */ 18 | func BenchmarkView(b *testing.B) { 19 | m := mapFrom("300x300.png") 20 | v := NewView(m, "view 1") 21 | v.Resize(NewRect(100, 0, 200, 100), nil) 22 | 23 | go func() { 24 | for range v.Inbox { 25 | } 26 | }() 27 | 28 | b.Run("write", func(b *testing.B) { 29 | b.ReportAllocs() 30 | b.ResetTimer() 31 | for n := 0; n < b.N; n++ { 32 | v.WriteAt(152, 52, Value(0)) 33 | } 34 | }) 35 | 36 | b.Run("move", func(b *testing.B) { 37 | locs := []Point{ 38 | At(100, 0), 39 | At(200, 100), 40 | } 41 | 42 | b.ReportAllocs() 43 | b.ResetTimer() 44 | for n := 0; n < b.N; n++ { 45 | v.MoveAt(locs[n%2], nil) 46 | } 47 | }) 48 | } 49 | 50 | func TestView(t *testing.T) { 51 | m := mapFrom("300x300.png") 52 | 53 | // Create a new view 54 | c := counter(0) 55 | v := NewView[string, string](m, "view 1") 56 | v.Resize(NewRect(100, 0, 200, 100), c.count) 57 | assert.NotNil(t, v) 58 | assert.Equal(t, 10000, int(c)) 59 | 60 | // Resize to 10x10 61 | c = counter(0) 62 | v.Resize(NewRect(0, 0, 10, 10), c.count) 63 | assert.Equal(t, 100, int(c)) 64 | 65 | // Move down-right 66 | c = counter(0) 67 | v.MoveBy(2, 2, c.count) 68 | assert.Equal(t, 48, int(c)) 69 | 70 | // Move at location 71 | c = counter(0) 72 | v.MoveAt(At(4, 4), c.count) 73 | assert.Equal(t, 48, int(c)) 74 | 75 | // Each 76 | c = counter(0) 77 | v.Each(c.count) 78 | assert.Equal(t, 100, int(c)) 79 | 80 | // Update a tile in view 81 | cursor, _ := v.At(5, 5) 82 | before := cursor.Value() 83 | v.WriteAt(5, 5, Value(55)) 84 | update := <-v.Inbox 85 | assert.Equal(t, At(5, 5), update.New.Point) 86 | assert.NotEqual(t, before, update.New) 87 | 88 | // Merge a tile in view, but with zero mask (won't do anything) 89 | cursor, _ = v.At(5, 5) 90 | before = cursor.Value() 91 | v.MergeAt(5, 5, Value(66), Value(0)) // zero mask 92 | update = <-v.Inbox 93 | assert.Equal(t, At(5, 5), update.New.Point) 94 | assert.Equal(t, before, update.New.Value) 95 | 96 | // Close the view 97 | assert.NoError(t, v.Close()) 98 | v.WriteAt(5, 5, Value(66)) 99 | assert.Equal(t, 0, len(v.Inbox)) 100 | } 101 | 102 | func TestUpdates_Simple(t *testing.T) { 103 | m := mapFrom("300x300.png") 104 | c := counter(0) 105 | v := NewView(m, "view 1") 106 | v.Resize(NewRect(0, 0, 10, 10), c.count) 107 | 108 | assert.NotNil(t, v) 109 | assert.Equal(t, 100, int(c)) 110 | 111 | // Update a tile in view 112 | cursor, _ := v.At(5, 5) 113 | cursor.Write(Value(0xF0)) 114 | assert.Equal(t, Update[string]{ 115 | Old: ValueAt{ 116 | Point: At(5, 5), 117 | }, 118 | New: ValueAt{ 119 | Point: At(5, 5), 120 | Value: Value(0xF0), 121 | }, 122 | }, <-v.Inbox) 123 | 124 | // Add an object to an observed tile 125 | cursor.Add("A") 126 | assert.Equal(t, Update[string]{ 127 | Old: ValueAt{ 128 | Point: At(5, 5), 129 | Value: Value(0xF0), 130 | }, 131 | New: ValueAt{ 132 | Point: At(5, 5), 133 | Value: Value(0xF0), 134 | }, 135 | Add: "A", 136 | }, <-v.Inbox) 137 | 138 | // Delete an object from an observed tile 139 | cursor.Del("A") 140 | assert.Equal(t, Update[string]{ 141 | Old: ValueAt{ 142 | Point: At(5, 5), 143 | Value: Value(0xF0), 144 | }, 145 | New: ValueAt{ 146 | Point: At(5, 5), 147 | Value: Value(0xF0), 148 | }, 149 | Del: "A", 150 | }, <-v.Inbox) 151 | 152 | // Mask a tile in view 153 | cursor.Mask(0xFF, 0x0F) 154 | assert.Equal(t, Update[string]{ 155 | Old: ValueAt{ 156 | Point: At(5, 5), 157 | Value: Value(0xF0), 158 | }, 159 | New: ValueAt{ 160 | Point: At(5, 5), 161 | Value: Value(0xFF), 162 | }, 163 | }, <-v.Inbox) 164 | 165 | // Merge a tile in view 166 | cursor.Merge(func(v Value) Value { 167 | return 0xAA 168 | }) 169 | assert.Equal(t, Update[string]{ 170 | Old: ValueAt{ 171 | Point: At(5, 5), 172 | Value: Value(0xFF), 173 | }, 174 | New: ValueAt{ 175 | Point: At(5, 5), 176 | Value: Value(0xAA), 177 | }, 178 | }, <-v.Inbox) 179 | } 180 | 181 | func TestMove_Within(t *testing.T) { 182 | m := mapFrom("300x300.png") 183 | c := counter(0) 184 | v := NewView(m, "view 1") 185 | v.Resize(NewRect(0, 0, 10, 10), c.count) 186 | 187 | // Add an object to an observed tile. This should only fire once since 188 | // both the old and new states are the observed by the view. 189 | cursor, _ := v.At(5, 5) 190 | cursor.Move("A", At(6, 6)) 191 | assert.Equal(t, Update[string]{ 192 | Old: ValueAt{ 193 | Point: At(5, 5), 194 | }, 195 | New: ValueAt{ 196 | Point: At(6, 6), 197 | }, 198 | Del: "A", 199 | Add: "A", 200 | }, <-v.Inbox) 201 | } 202 | 203 | func TestMove_Incoming(t *testing.T) { 204 | m := mapFrom("300x300.png") 205 | c := counter(0) 206 | v := NewView(m, "view 1") 207 | v.Resize(NewRect(0, 0, 10, 10), c.count) 208 | 209 | // Add an object to an observed tile from outside the view. 210 | cursor, _ := v.At(20, 20) 211 | cursor.Move("A", At(5, 5)) 212 | assert.Equal(t, Update[string]{ 213 | Old: ValueAt{ 214 | Point: At(20, 20), 215 | }, 216 | New: ValueAt{ 217 | Point: At(5, 5), 218 | }, 219 | Del: "A", 220 | Add: "A", 221 | }, <-v.Inbox) 222 | } 223 | 224 | func TestMove_Outgoing(t *testing.T) { 225 | m := mapFrom("300x300.png") 226 | c := counter(0) 227 | v := NewView(m, "view 1") 228 | v.Resize(NewRect(0, 0, 10, 10), c.count) 229 | 230 | // Move an object from an observed tile outside of the view. 231 | cursor, _ := v.At(5, 5) 232 | cursor.Move("A", At(20, 20)) 233 | assert.Equal(t, Update[string]{ 234 | Old: ValueAt{ 235 | Point: At(5, 5), 236 | }, 237 | New: ValueAt{ 238 | Point: At(20, 20), 239 | }, 240 | Del: "A", 241 | Add: "A", 242 | }, <-v.Inbox) 243 | } 244 | 245 | func TestView_MoveTo(t *testing.T) { 246 | m := mapFrom("300x300.png") 247 | 248 | // Create a new view 249 | c := counter(0) 250 | v := NewView(m, "view 1") 251 | v.Resize(NewRect(10, 10, 12, 12), c.count) 252 | 253 | assert.NotNil(t, v) 254 | assert.Equal(t, 4, int(c)) 255 | assert.Equal(t, 9, countObservers(m)) 256 | 257 | const distance = 10 258 | 259 | assert.Equal(t, 1, countObserversAt(m, 10, 10)) 260 | for i := 0; i < distance; i++ { 261 | v.MoveTo(East, 1, c.count) 262 | } 263 | 264 | assert.Equal(t, 0, countObserversAt(m, 10, 10)) 265 | for i := 0; i < distance; i++ { 266 | v.MoveTo(South, 1, c.count) 267 | } 268 | 269 | assert.Equal(t, 0, countObserversAt(m, 10, 10)) 270 | for i := 0; i < distance; i++ { 271 | v.MoveTo(West, 1, c.count) 272 | } 273 | 274 | assert.Equal(t, 0, countObserversAt(m, 10, 10)) 275 | for i := 0; i < distance; i++ { 276 | v.MoveTo(North, 1, c.count) 277 | } 278 | 279 | // Start should have the observer attached 280 | assert.Equal(t, 1, countObserversAt(m, 10, 10)) 281 | assert.Equal(t, 0, countObserversAt(m, 100, 100)) 282 | 283 | // Count the number of observers, should be the same as before 284 | assert.Equal(t, 9, countObservers(m)) 285 | assert.NoError(t, v.Close()) 286 | } 287 | 288 | func TestView_Updates(t *testing.T) { 289 | m := mapFrom("300x300.png") 290 | v := NewView(m, "view 1") 291 | v.Resize(NewRect(10, 10, 15, 15), nil) 292 | 293 | move := func(x1, y1, x2, y2 int16) { 294 | at, _ := m.At(x1, y1) 295 | at.Move("A", At(x2, y2)) 296 | 297 | assert.Equal(t, Update[string]{ 298 | Old: ValueAt{Point: At(x1, y1)}, 299 | New: ValueAt{Point: At(x2, y2)}, 300 | Del: "A", Add: "A", 301 | }, <-v.Inbox) 302 | } 303 | 304 | move(9, 12, 10, 12) // Enter from left edge 305 | move(10, 12, 9, 12) // Exit to left edge 306 | move(15, 12, 14, 12) // Enter from right edge 307 | move(14, 12, 15, 12) // Exit to right edge 308 | move(12, 9, 12, 10) // Enter from top edge 309 | move(12, 10, 12, 9) // Exit to top edge 310 | move(12, 15, 12, 14) // Enter from bottom edge 311 | move(12, 14, 12, 15) // Exit to bottom edge 312 | move(9, 9, 10, 10) // Enter from top-left diagonal 313 | move(10, 10, 9, 9) // Exit to top-left diagonal 314 | move(15, 9, 14, 10) // Enter from top-right diagonal 315 | move(14, 10, 15, 9) // Exit to top-right diagonal 316 | move(9, 15, 10, 14) // Enter from bottom-left diagonal 317 | move(10, 14, 9, 15) // Exit to bottom-left diagonal 318 | move(15, 15, 14, 14) // Enter from bottom-right diagonal 319 | move(14, 14, 15, 15) // Exit to bottom-right diagonal 320 | 321 | assert.NoError(t, v.Close()) 322 | } 323 | 324 | func TestSizeUpdate(t *testing.T) { 325 | assert.Equal(t, 24, int(unsafe.Sizeof(Update[uint32]{}))) 326 | } 327 | 328 | // ---------------------------------- Mocks ---------------------------------- 329 | 330 | func countObserversAt(m *Grid[string], x, y int16) (count int) { 331 | start, _ := m.At(x, y) 332 | start.Observers(func(view Observer[string]) { 333 | count++ 334 | }) 335 | return count 336 | } 337 | 338 | func countObservers(m *Grid[string]) int { 339 | var observers int 340 | m.Each(func(p Point, t Tile[string]) { 341 | if t.data.IsObserved() { 342 | observers++ 343 | } 344 | }) 345 | return observers 346 | } 347 | 348 | type fakeView[T comparable] func(*Update[T]) 349 | 350 | func (f fakeView[T]) Viewport() Rect { 351 | return Rect{} 352 | } 353 | 354 | func (f fakeView[T]) Resize(r Rect, fn func(Point, Tile[T])) { 355 | // Do nothing 356 | } 357 | 358 | func (f fakeView[T]) onUpdate(e *Update[T]) { 359 | f(e) 360 | } 361 | 362 | type counter int 363 | 364 | func (c *counter) count(p Point, tile Tile[string]) { 365 | *c++ 366 | } 367 | --------------------------------------------------------------------------------