Repository: kelindar/tile Branch: master Commit: c3a5eaf85efd Files: 17 Total size: 88.2 KB Directory structure: gitextract_bvjbbodm/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: [kelindar] ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] env: GITHUB_TOKEN: ${{ secrets.COVERALLS_TOKEN }} GO111MODULE: "on" jobs: test: name: Test with Coverage runs-on: ubuntu-latest steps: - name: Set up Go uses: actions/setup-go@v1 with: go-version: 1.23 - name: Check out code uses: actions/checkout@v2 - name: Install dependencies run: | go mod download - name: Run Unit Tests run: | go test -race -covermode atomic -coverprofile=profile.cov ./... - name: Upload Coverage uses: shogo82148/actions-goveralls@v1 with: path-to-profile: profile.cov ================================================ FILE: .gitignore ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Roman Atachiants Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Tile: Data-Oriented 2D Grid Engine
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. - **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. - **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. - **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. - **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. - **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. _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! # Grid & Tiles 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 - Value `uint32` of the tile, that can be used for calculating navigation or quickly retrieve sprite index. - 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. 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. 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. ```go grid := tile.NewGridOf[string](1000, 1000) ``` 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. ```go grid.Each(func(p Point, t tile.Tile[string]) { // ... }) ``` 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. ```go grid.Within(At(1, 1), At(5, 5), func(p Point, t tile.Tile[string]) { // ... }) ``` 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. ```go if tile, ok := grid.At(50, 100); ok { // ... } ``` 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). ```go grid.WriteAt(50, 100, tile.Value(0xFF)) ``` 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. ```go grid.Neighbors(50, 100, func(point tile.Point, t tile.Tile[string]) { // ... }) ``` 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. ```go grid.MergeAt(50, 100, func(v Value) Value { v += 1 return v }) ``` 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()`. ```go // assume byte[0] of the tile is 0b01010001 grid.MaskAt(50, 100, 0b00101110, // Only last 2 bits matter 0b00000011 // Mask specifies that we want to update last 2 bits ) // If the original is currently: 0b01010001 // ...the result result will be: 0b01010010 ``` # Pathfinding 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. 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. ```go from := At(1, 1) goal := At(7, 7) path, distance, found := m.Path(from, goal, func(v tile.Value) uint16{ if isImpassable(v) { return 0 } return 1 }) ``` 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. ```go point := At(50, 50) radius := 5 m.Around(point, radius, func(v tile.Value) uint16{ if isImpassable(v) { return 0 } return 1 }, func(p tile.Point, t tile.Tile[string]) { // ... tile found }) ``` # Observers 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. 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. In the example below we create a new 20x20 view on the grid and iterate through all of the tiles in the view. ```go view := tile.NewView[string, string](grid, "My View #1") view.Resize(tile.NewRect(0, 0, 20, 20), func(p tile.Point, t tile.Tile){ // Optional, all of the tiles that are in the view now }) // Poll the inbox (in reality this would need to be with a select, and a goroutine) for { update := <-view.Inbox // Do something with update.Point, update.Tile } ``` 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). ```go view.MoveBy(0, 5, func(p tile.Point, tile tile.Tile){ // Every tile which entered our view }) ``` 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. ```go view.MoveAt(At(10, 10), func(p tile.Point, t tile.Tile){ // Every tile which entered our view }) ``` 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. ```go viewRect := tile.NewRect(10, 10, 30, 30) view.Resize(viewRect, func(p tile.Point, t tile.Tile){ // Every tile which entered our view }) ``` 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. ```go // Unsubscribe from notifications and close the view view.Close() ``` # Save & Load 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. 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. ```go // Prepare the output buffer and compressor output := new(bytes.Buffer) writer, err := flate.NewWriter(output, flate.BestSpeed) if err != nil { // ... } defer writer.Close() // Make sure we flush the compressor _, err := grid.WriteTo(writer) // Write the grid if err != nil { // ... } ``` 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. ```go // Prepare a compressed reader over the buffer reader := flate.NewReader(output) // Read the Grid grid, err := ReadFrom(reader) if err != nil{ // ... } ``` # Benchmarks 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. ``` cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz BenchmarkGrid/each-8 868 1358434 ns/op 0 B/op 0 allocs/op BenchmarkGrid/neighbors-8 66551679 17.87 ns/op 0 B/op 0 allocs/op BenchmarkGrid/within-8 27207 44753 ns/op 0 B/op 0 allocs/op BenchmarkGrid/at-8 399067512 2.994 ns/op 0 B/op 0 allocs/op BenchmarkGrid/write-8 130207965 9.294 ns/op 0 B/op 0 allocs/op BenchmarkGrid/merge-8 124156794 9.663 ns/op 0 B/op 0 allocs/op BenchmarkGrid/mask-8 100000000 10.67 ns/op 0 B/op 0 allocs/op BenchmarkState/range-8 12106854 98.91 ns/op 0 B/op 0 allocs/op BenchmarkState/add-8 48827727 25.43 ns/op 0 B/op 0 allocs/op BenchmarkState/del-8 52110474 21.59 ns/op 0 B/op 0 allocs/op BenchmarkPath/9x9-8 264586 4656 ns/op 16460 B/op 3 allocs/op BenchmarkPath/300x300-8 601 1937662 ns/op 7801502 B/op 4 allocs/op BenchmarkPath/381x381-8 363 3304134 ns/op 62394356 B/op 5 allocs/op BenchmarkPath/384x384-8 171 7165777 ns/op 62394400 B/op 5 allocs/op BenchmarkPath/3069x3069-8 31 36479106 ns/op 124836075 B/op 4 allocs/op BenchmarkPath/3072x3072-8 30 34889740 ns/op 124837686 B/op 4 allocs/op BenchmarkPath/6144x6144-8 142 7594013 ns/op 62395376 B/op 3 allocs/op BenchmarkAround/3r-8 506857 2384 ns/op 385 B/op 1 allocs/op BenchmarkAround/5r-8 214280 5539 ns/op 922 B/op 2 allocs/op BenchmarkAround/10r-8 85723 14017 ns/op 3481 B/op 2 allocs/op BenchmarkPoint/within-8 1000000000 0.2190 ns/op 0 B/op 0 allocs/op BenchmarkPoint/within-rect-8 1000000000 0.2195 ns/op 0 B/op 0 allocs/op BenchmarkStore/save-8 14577 82510 ns/op 8 B/op 1 allocs/op BenchmarkStore/read-8 3199 364771 ns/op 647419 B/op 7 allocs/op BenchmarkView/write-8 6285351 188.2 ns/op 48 B/op 1 allocs/op BenchmarkView/move-8 10000 116953 ns/op 0 B/op 0 allocs/op ``` # Contributing 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/) ## License Tile is licensed under the [MIT License](LICENSE.md). ================================================ FILE: go.mod ================================================ module github.com/kelindar/tile go 1.25 require ( github.com/kelindar/intmap v1.5.0 github.com/kelindar/iostream v1.4.0 github.com/stretchr/testify v1.10.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kelindar/intmap v1.5.0 h1:VY+AdO4Wx1sF1vGiTkS8n2lxhmFgOQwCIFuePQP4Iqw= github.com/kelindar/intmap v1.5.0/go.mod h1:NkypxhfaklmDTJqwano3Q1BWk6je77qgQwszDwu8Kc8= github.com/kelindar/iostream v1.4.0 h1:ELKlinnM/K3GbRp9pYhWuZOyBxMMlYAfsOP+gauvZaY= github.com/kelindar/iostream v1.4.0/go.mod h1:MkjMuVb6zGdPQVdwLnFRO0xOTOdDvBWTztFmjRDQkXk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: grid.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "sync" "sync/atomic" ) // Grid represents a 2D tile map. Internally, a map is composed of 3x3 pages. type Grid[T comparable] struct { pages []page[T] // The pages of the map pageWidth int16 // The max page width pageHeight int16 // The max page height observers pubsub[T] // The map of observers Size Point // The map size } // NewGrid returns a new map of the specified size. The width and height must be both // multiples of 3. func NewGrid(width, height int16) *Grid[string] { return NewGridOf[string](width, height) } // NewGridOf returns a new map of the specified size. The width and height must be both // multiples of 3. func NewGridOf[T comparable](width, height int16) *Grid[T] { width, height = width/3, height/3 max := int32(width) * int32(height) pages := make([]page[T], max) m := &Grid[T]{ pages: pages, pageWidth: width, pageHeight: height, Size: At(width*3, height*3), observers: pubsub[T]{ tmp: sync.Pool{ New: func() any { return make(map[Observer[T]]struct{}, 4) }, }, }, } // Function to calculate a point based on the index var pointAt func(i int) Point = func(i int) Point { return At(int16(i%int(width)), int16(i/int(width))) } for i := 0; i < int(max); i++ { pages[i].point = pointAt(i).MultiplyScalar(3) } return m } // Each iterates over all of the tiles in the map. func (m *Grid[T]) Each(fn func(Point, Tile[T])) { until := int(m.pageHeight) * int(m.pageWidth) for i := 0; i < until; i++ { m.pages[i].Each(m, fn) } } // Within selects the tiles within a specifid bounding box which is specified by // north-west and south-east coordinates. func (m *Grid[T]) Within(nw, se Point, fn func(Point, Tile[T])) { m.pagesWithin(nw, se, func(page *page[T]) { page.Each(m, func(p Point, v Tile[T]) { if p.Within(nw, se) { fn(p, v) } }) }) } // pagesWithin selects the pages within a specifid bounding box which is specified // by north-west and south-east coordinates. func (m *Grid[T]) pagesWithin(nw, se Point, fn func(*page[T])) { if !se.WithinSize(m.Size) { se = At(m.Size.X-1, m.Size.Y-1) } for x := nw.X / 3; x <= se.X/3; x++ { for y := nw.Y / 3; y <= se.Y/3; y++ { fn(m.pageAt(x, y)) } } } // At returns the tile at a specified position func (m *Grid[T]) At(x, y int16) (Tile[T], bool) { if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { return m.pageAt(x/3, y/3).At(m, x, y), true } return Tile[T]{}, false } // WriteAt updates the entire tile value at a specific coordinate func (m *Grid[T]) WriteAt(x, y int16, tile Value) { if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { m.pageAt(x/3, y/3).writeTile(m, uint8((y%3)*3+(x%3)), tile) } } // MaskAt atomically updates the bits of tile at a specific coordinate. The bits are // specified by the mask. The bits that need to be updated should be flipped on in the mask. func (m *Grid[T]) MaskAt(x, y int16, tile, mask Value) { m.MergeAt(x, y, func(value Value) Value { return (value &^ mask) | (tile & mask) }) } // Merge atomically merges the tile by applying a merging function at a specific coordinate. func (m *Grid[T]) MergeAt(x, y int16, merge func(Value) Value) { if x >= 0 && y >= 0 && x < m.Size.X && y < m.Size.Y { m.pageAt(x/3, y/3).mergeTile(m, uint8((y%3)*3+(x%3)), merge) } } // Neighbors iterates over the direct neighbouring tiles func (m *Grid[T]) Neighbors(x, y int16, fn func(Point, Tile[T])) { // First we need to figure out which pages contain the neighboring tiles and // then load them. In the best-case we need to load only a single page. In // the worst-case: we need to load 3 pages. nX, nY := x/3, (y-1)/3 // North eX, eY := (x+1)/3, y/3 // East sX, sY := x/3, (y+1)/3 // South wX, wY := (x-1)/3, y/3 // West // Get the North if y > 0 { fn(At(x, y-1), m.pageAt(nX, nY).At(m, x, y-1)) } // Get the East if eX < m.pageWidth { fn(At(x+1, y), m.pageAt(eX, eY).At(m, x+1, y)) } // Get the South if sY < m.pageHeight { fn(At(x, y+1), m.pageAt(sX, sY).At(m, x, y+1)) } // Get the West if x > 0 { fn(At(x-1, y), m.pageAt(wX, wY).At(m, x-1, y)) } } // pageAt loads a page at a given page location func (m *Grid[T]) pageAt(x, y int16) *page[T] { index := int(x) + int(m.pageWidth)*int(y) // Eliminate bounds checks if index >= 0 && index < len(m.pages) { return &m.pages[index] } return nil } // ---------------------------------- Tile ---------------------------------- // Value represents a packed tile information, it must fit on 4 bytes. type Value = uint32 // ---------------------------------- Page ---------------------------------- // page represents a 3x3 tile page each page should neatly fit on a cache // line and speed things up. type page[T comparable] struct { mu sync.Mutex // State lock, 8 bytes state map[T]uint8 // State data, 8 bytes flags uint32 // Page flags, 4 bytes point Point // Page X, Y coordinate, 4 bytes tiles [9]Value // Page tiles, 36 bytes } // tileAt reads a tile at a page index func (p *page[T]) tileAt(idx uint8) Value { return Value(atomic.LoadUint32((*uint32)(&p.tiles[idx]))) } // IsObserved returns whether the tile is observed or not func (p *page[T]) IsObserved() bool { return (atomic.LoadUint32(&p.flags))&1 != 0 } // Bounds returns the bounding box for the tile page. func (p *page[T]) Bounds() Rect { return Rect{p.point, At(p.point.X+3, p.point.Y+3)} } // At returns a cursor at a specific coordinate func (p *page[T]) At(grid *Grid[T], x, y int16) Tile[T] { return Tile[T]{grid: grid, data: p, idx: uint8((y%3)*3 + (x % 3))} } // Each iterates over all of the tiles in the page. func (p *page[T]) Each(grid *Grid[T], fn func(Point, Tile[T])) { x, y := p.point.X, p.point.Y fn(Point{x, y}, Tile[T]{grid: grid, data: p, idx: 0}) // NW fn(Point{x + 1, y}, Tile[T]{grid: grid, data: p, idx: 1}) // N fn(Point{x + 2, y}, Tile[T]{grid: grid, data: p, idx: 2}) // NE fn(Point{x, y + 1}, Tile[T]{grid: grid, data: p, idx: 3}) // W fn(Point{x + 1, y + 1}, Tile[T]{grid: grid, data: p, idx: 4}) // C fn(Point{x + 2, y + 1}, Tile[T]{grid: grid, data: p, idx: 5}) // E fn(Point{x, y + 2}, Tile[T]{grid: grid, data: p, idx: 6}) // SW fn(Point{x + 1, y + 2}, Tile[T]{grid: grid, data: p, idx: 7}) // S fn(Point{x + 2, y + 2}, Tile[T]{grid: grid, data: p, idx: 8}) // SE } // SetObserved sets the observed flag on the page func (p *page[T]) SetObserved(observed bool) { const flagObserved = 0x1 for { value := atomic.LoadUint32(&p.flags) merge := value if observed { merge = value | flagObserved } else { merge = value &^ flagObserved } if atomic.CompareAndSwapUint32(&p.flags, value, merge) { break } } } // Lock locks the state. Note: this needs to be named Lock() so go vet will // complain if the page is copied around. func (p *page[T]) Lock() { p.mu.Lock() } // Unlock unlocks the state. Note: this needs to be named Unlock() so go vet will // complain if the page is copied around. func (p *page[T]) Unlock() { p.mu.Unlock() } // ---------------------------------- Mutations ---------------------------------- // writeTile stores the tile and return whether tile is observed or not func (p *page[T]) writeTile(grid *Grid[T], idx uint8, after Value) { before := p.tileAt(idx) for !atomic.CompareAndSwapUint32(&p.tiles[idx], uint32(before), uint32(after)) { before = p.tileAt(idx) } // If observed, notify the observers of the tile if p.IsObserved() { at := pointOf(p.point, idx) grid.observers.Notify1(&Update[T]{ Old: ValueAt{ Point: at, Value: before, }, New: ValueAt{ Point: at, Value: after, }, }, p.point) } } // mergeTile atomically merges the tile bits given a function func (p *page[T]) mergeTile(grid *Grid[T], idx uint8, fn func(Value) Value) Value { before := p.tileAt(idx) after := fn(before) // Swap, if we're not able to re-merge again for !atomic.CompareAndSwapUint32(&p.tiles[idx], uint32(before), uint32(after)) { before = p.tileAt(idx) after = fn(before) } // If observed, notify the observers of the tile if p.IsObserved() { at := pointOf(p.point, idx) grid.observers.Notify1(&Update[T]{ Old: ValueAt{ Point: at, Value: before, }, New: ValueAt{ Point: at, Value: after, }, }, p.point) } // Return the merged tile data return after } // addObject adds object to the set func (p *page[T]) addObject(idx uint8, object T) (value uint32) { p.Lock() // Lazily initialize the map, as most pages might not have anything stored // in them (e.g. water or empty tile) if p.state == nil { p.state = make(map[T]uint8) } p.state[object] = uint8(idx) value = p.tileAt(idx) p.Unlock() return } // delObject removes the object from the set func (p *page[T]) delObject(idx uint8, object T) (value uint32) { p.Lock() if p.state != nil { delete(p.state, object) } value = p.tileAt(idx) p.Unlock() return } // ---------------------------------- Tile Cursor ---------------------------------- // Tile represents an iterator over all state objects at a particular location. type Tile[T comparable] struct { grid *Grid[T] // grid pointer data *page[T] // page pointer idx uint8 // tile index } // Count returns number of objects at the current tile. func (t Tile[T]) Count() (count int) { t.data.Lock() defer t.data.Unlock() for _, idx := range t.data.state { if idx == uint8(t.idx) { count++ } } return } // Point returns the point of the tile func (t Tile[T]) Point() Point { return pointOf(t.data.point, t.idx) } // Value reads the tile information func (t Tile[T]) Value() Value { return t.data.tileAt(t.idx) } // Range iterates over all of the objects in the set func (t Tile[T]) Range(fn func(T) error) error { t.data.Lock() defer t.data.Unlock() for v, idx := range t.data.state { if idx == uint8(t.idx) { if err := fn(v); err != nil { return err } } } return nil } // IsObserved returns whether the tile is observed or not func (t Tile[T]) IsObserved() bool { return t.data.IsObserved() } // Observers iterates over all views observing this tile func (t Tile[T]) Observers(fn func(view Observer[T])) { if !t.data.IsObserved() { return } t.grid.observers.Each1(func(sub Observer[T]) { if sub.Viewport().Contains(t.Point()) { fn(sub) } }, t.data.point) } // Add adds object to the set func (t Tile[T]) Add(v T) { value := t.data.addObject(t.idx, v) // If observed, notify the observers of the tile if t.data.IsObserved() { at := t.Point() t.grid.observers.Notify1(&Update[T]{ Old: ValueAt{ Point: at, Value: value, }, New: ValueAt{ Point: at, Value: value, }, Add: v, }, t.data.point) } } // Del removes the object from the set func (t Tile[T]) Del(v T) { value := t.data.delObject(t.idx, v) // If observed, notify the observers of the tile if t.data.IsObserved() { at := t.Point() t.grid.observers.Notify1(&Update[T]{ Old: ValueAt{ Point: at, Value: value, }, New: ValueAt{ Point: at, Value: value, }, Del: v, }, t.data.point) } } // Move moves an object from the current tile to the destination tile. func (t Tile[T]) Move(v T, dst Point) bool { d, ok := t.grid.At(dst.X, dst.Y) if !ok { return false } // Move the object from the source to the destination tv := t.data.delObject(d.idx, v) dv := d.data.addObject(d.idx, v) if !t.data.IsObserved() && !d.data.IsObserved() { return true } // Prepare the update notification update := &Update[T]{ Old: ValueAt{ Point: t.Point(), Value: tv, }, New: ValueAt{ Point: d.Point(), Value: dv, }, Del: v, Add: v, } switch { case t.data == d.data || !d.data.IsObserved(): t.grid.observers.Notify1(update, t.data.point) case !t.data.IsObserved(): t.grid.observers.Notify1(update, d.data.point) default: t.grid.observers.Notify2(update, [2]Point{ t.data.point, d.data.point, }) } return true } // Write updates the entire tile value. func (t Tile[T]) Write(tile Value) { t.data.writeTile(t.grid, t.idx, tile) } // Merge atomically merges the tile by applying a merging function. func (t Tile[T]) Merge(merge func(Value) Value) Value { return t.data.mergeTile(t.grid, t.idx, merge) } // Mask updates the bits of tile. The bits are specified by the mask. The bits // that need to be updated should be flipped on in the mask. func (t Tile[T]) Mask(tile, mask Value) Value { return t.data.mergeTile(t.grid, t.idx, func(value Value) Value { return (value &^ mask) | (tile & mask) }) } // pointOf returns the point given an index func pointOf(page Point, idx uint8) Point { return Point{ X: page.X + int16(idx)%3, Y: page.Y + int16(idx)/3, } } ================================================ FILE: grid_test.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "fmt" "io" "sync" "testing" "unsafe" "github.com/stretchr/testify/assert" ) /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkGrid/each-24 1452 830268 ns/op 0 B/op 0 allocs/op BenchmarkGrid/neighbors-24 121583491 9.861 ns/op 0 B/op 0 allocs/op BenchmarkGrid/within-24 49360 24477 ns/op 0 B/op 0 allocs/op BenchmarkGrid/at-24 687659378 1.741 ns/op 0 B/op 0 allocs/op BenchmarkGrid/write-24 191272338 6.307 ns/op 0 B/op 0 allocs/op BenchmarkGrid/merge-24 162536985 7.332 ns/op 0 B/op 0 allocs/op BenchmarkGrid/mask-24 158258084 7.601 ns/op 0 B/op 0 allocs/op */ func BenchmarkGrid(b *testing.B) { var d Tile[uint32] var p Point defer assert.NotNil(b, d) m := NewGridOf[uint32](768, 768) b.Run("each", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Each(func(point Point, tile Tile[uint32]) { p = point d = tile }) } }) b.Run("neighbors", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Neighbors(300, 300, func(point Point, tile Tile[uint32]) { p = point d = tile }) } }) b.Run("within", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Within(At(100, 100), At(200, 200), func(point Point, tile Tile[uint32]) { p = point d = tile }) } }) assert.NotZero(b, p.X) b.Run("at", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { d, _ = m.At(100, 100) } }) b.Run("write", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.WriteAt(100, 100, Value(0)) } }) b.Run("merge", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.MergeAt(100, 100, func(v Value) Value { v += 1 return v }) } }) b.Run("mask", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.MaskAt(100, 100, Value(0), Value(1)) } }) } /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkState/range-24 17017800 71.14 ns/op 0 B/op 0 allocs/op BenchmarkState/add-24 72639224 16.32 ns/op 0 B/op 0 allocs/op BenchmarkState/del-24 82469125 13.65 ns/op 0 B/op 0 allocs/op */ func BenchmarkState(b *testing.B) { m := NewGridOf[int](768, 768) m.Each(func(p Point, c Tile[int]) { for i := 0; i < 10; i++ { c.Add(i) } }) b.Run("range", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { cursor, _ := m.At(100, 100) cursor.Range(func(v int) error { return nil }) } }) b.Run("add", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { cursor, _ := m.At(100, 100) cursor.Add(100) } }) b.Run("del", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { cursor, _ := m.At(100, 100) cursor.Del(100) } }) } func TestPageSize(t *testing.T) { assert.Equal(t, 8, int(unsafe.Sizeof(map[uintptr]Point{}))) assert.Equal(t, 64, int(unsafe.Sizeof(page[string]{}))) assert.Equal(t, 36, int(unsafe.Sizeof([9]Value{}))) } func TestWithin(t *testing.T) { m := NewGrid(9, 9) var path []string m.Within(At(1, 1), At(5, 5), func(p Point, tile Tile[string]) { path = append(path, p.String()) }) assert.Equal(t, 16, len(path)) assert.ElementsMatch(t, []string{ "1,1", "2,1", "1,2", "2,2", "3,1", "4,1", "3,2", "4,2", "1,3", "2,3", "1,4", "2,4", "3,3", "4,3", "3,4", "4,4", }, path) } func TestWithinCorner(t *testing.T) { m := NewGrid(9, 9) var path []string m.Within(At(7, 6), At(10, 10), func(p Point, tile Tile[string]) { path = append(path, p.String()) }) assert.Equal(t, 6, len(path)) assert.ElementsMatch(t, []string{ "7,6", "8,6", "7,7", "8,7", "7,8", "8,8", }, path) } func TestWithinXY(t *testing.T) { assert.False(t, At(4, 8).WithinRect(NewRect(1, 6, 4, 10))) } func TestWithinOneSide(t *testing.T) { m := NewGrid(9, 9) var path []string m.Within(At(1, 6), At(4, 10), func(p Point, tile Tile[string]) { path = append(path, p.String()) }) assert.Equal(t, 9, len(path)) assert.ElementsMatch(t, []string{ "1,6", "2,6", "3,6", "1,7", "2,7", "3,7", "1,8", "2,8", "3,8", }, path) } func TestWithinInvalid(t *testing.T) { m := NewGrid(9, 9) count := 0 m.Within(At(10, 10), At(20, 20), func(p Point, tile Tile[string]) { count++ }) assert.Equal(t, 0, count) } func TestEach(t *testing.T) { m := NewGrid(9, 9) var path []string m.Each(func(p Point, tile Tile[string]) { path = append(path, p.String()) }) assert.Equal(t, 81, len(path)) assert.ElementsMatch(t, []string{ "0,0", "1,0", "2,0", "0,1", "1,1", "2,1", "0,2", "1,2", "2,2", "0,3", "1,3", "2,3", "0,4", "1,4", "2,4", "0,5", "1,5", "2,5", "0,6", "1,6", "2,6", "0,7", "1,7", "2,7", "0,8", "1,8", "2,8", "3,0", "4,0", "5,0", "3,1", "4,1", "5,1", "3,2", "4,2", "5,2", "3,3", "4,3", "5,3", "3,4", "4,4", "5,4", "3,5", "4,5", "5,5", "3,6", "4,6", "5,6", "3,7", "4,7", "5,7", "3,8", "4,8", "5,8", "6,0", "7,0", "8,0", "6,1", "7,1", "8,1", "6,2", "7,2", "8,2", "6,3", "7,3", "8,3", "6,4", "7,4", "8,4", "6,5", "7,5", "8,5", "6,6", "7,6", "8,6", "6,7", "7,7", "8,7", "6,8", "7,8", "8,8", }, path) } func TestNeighbors(t *testing.T) { tests := []struct { x, y int16 expect []string }{ {x: 0, y: 0, expect: []string{"1,0", "0,1"}}, {x: 1, y: 0, expect: []string{"2,0", "1,1", "0,0"}}, {x: 1, y: 1, expect: []string{"1,0", "2,1", "1,2", "0,1"}}, {x: 2, y: 2, expect: []string{"2,1", "3,2", "2,3", "1,2"}}, {x: 8, y: 8, expect: []string{"8,7", "7,8"}}, } // Create a 9x9 map with labeled tiles m := NewGrid(9, 9) m.Each(func(p Point, tile Tile[string]) { m.WriteAt(p.X, p.Y, Value(p.Integer())) }) // Run all the tests for _, tc := range tests { var out []string m.Neighbors(tc.x, tc.y, func(_ Point, tile Tile[string]) { loc := unpackPoint(uint32(tile.Value())) out = append(out, loc.String()) }) assert.ElementsMatch(t, tc.expect, out) } } func TestAt(t *testing.T) { // Create a 9x9 map with labeled tiles m := NewGrid(9, 9) m.Each(func(p Point, tile Tile[string]) { m.WriteAt(p.X, p.Y, Value(p.Integer())) }) // Make sure our At() and the position matches m.Each(func(p Point, tile Tile[string]) { at, _ := m.At(p.X, p.Y) assert.Equal(t, p.String(), unpackPoint(uint32(at.Value())).String()) }) // Make sure that points match for y := int16(0); y < 9; y++ { for x := int16(0); x < 9; x++ { at, _ := m.At(x, y) assert.Equal(t, At(x, y).String(), unpackPoint(uint32(at.Value())).String()) } } } func TestUpdate(t *testing.T) { // Create a 9x9 map with labeled tiles m := NewGrid(9, 9) i := 0 m.Each(func(p Point, _ Tile[string]) { i++ m.WriteAt(p.X, p.Y, Value(i)) }) // Assert the update cursor, _ := m.At(8, 8) assert.Equal(t, 81, int(cursor.Value())) // 81 = 0b01010001 delta := Value(0b00101110) // change last 2 bits and should ignore other bits m.MaskAt(8, 8, delta, Value(0b00000011)) // original: 0101 0001 // delta: 0010 1110 // mask: 0000 0011 // result: 0101 0010 cursor, _ = m.At(8, 8) assert.Equal(t, 0b01010010, int(cursor.Value())) } func TestState(t *testing.T) { m := NewGrid(9, 9) m.Each(func(p Point, c Tile[string]) { c.Add(p.String()) c.Add(p.String()) // duplicate }) m.Each(func(p Point, c Tile[string]) { assert.Equal(t, 1, c.Count()) assert.NoError(t, c.Range(func(s string) error { assert.Equal(t, p.String(), s) return nil })) c.Del(p.String()) assert.Equal(t, 0, c.Count()) }) } func TestStateRangeErr(t *testing.T) { m := NewGrid(9, 9) m.Each(func(p Point, c Tile[string]) { c.Add(p.String()) }) m.Each(func(p Point, c Tile[string]) { assert.Error(t, c.Range(func(s string) error { return io.EOF })) }) } func TestPointOf(t *testing.T) { truthTable := func(x, y int16, idx uint8) (int16, int16) { switch idx { case 0: return x, y case 1: return x + 1, y case 2: return x + 2, y case 3: return x, y + 1 case 4: return x + 1, y + 1 case 5: return x + 2, y + 1 case 6: return x, y + 2 case 7: return x + 1, y + 2 case 8: return x + 2, y + 2 default: return x, y } } for i := 0; i < 9; i++ { at := pointOf(At(0, 0), uint8(i)) x, y := truthTable(0, 0, uint8(i)) assert.Equal(t, x, at.X, fmt.Sprintf("idx=%v", i)) assert.Equal(t, y, at.Y, fmt.Sprintf("idx=%v", i)) } } func TestConcurrentMerge(t *testing.T) { const count = 10000 var wg sync.WaitGroup wg.Add(count) m := NewGrid(9, 9) for i := 0; i < count; i++ { go func() { m.MergeAt(1, 1, func(v Value) Value { v += 1 return v }) wg.Done() }() } wg.Wait() tile, ok := m.At(1, 1) assert.True(t, ok) assert.Equal(t, uint32(count), tile.Value()) } ================================================ FILE: path.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "math" "math/bits" "sync" "github.com/kelindar/intmap" ) type costFn = func(Value) uint16 // Edge represents an edge of the path type edge struct { Point Cost uint32 } // Around performs a breadth first search around a point. func (m *Grid[T]) Around(from Point, distance uint32, costOf costFn, fn func(Point, Tile[T])) { start, ok := m.At(from.X, from.Y) if !ok { return } fn(from, start) // For pre-allocating, we use πr2 since BFS will result in a approximation // of a circle, in the worst case. maxArea := int(math.Ceil(math.Pi * float64(distance*distance))) // Acquire a frontier heap for search state := acquire(maxArea) frontier := state.frontier reached := state.edges defer release(state) frontier.Push(from.Integer(), 0) reached.Store(from.Integer(), 0) for !frontier.IsEmpty() { pCurr := frontier.Pop() current := unpackPoint(pCurr) // Get all of the neighbors m.Neighbors(current.X, current.Y, func(next Point, nextTile Tile[T]) { if d := from.DistanceTo(next); d > distance { return // Too far } if cost := costOf(nextTile.Value()); cost == 0 { return // Blocked tile, ignore completely } // Add to the search queue pNext := next.Integer() if _, ok := reached.Load(pNext); !ok { frontier.Push(pNext, 1) reached.Store(pNext, 1) fn(next, nextTile) } }) } } // Path calculates a short path and the distance between the two locations func (m *Grid[T]) Path(from, to Point, costOf costFn) ([]Point, int, bool) { distance := float64(from.DistanceTo(to)) maxArea := int(math.Ceil(math.Pi * float64(distance*distance))) // For pre-allocating, we use πr2 since BFS will result in a approximation // of a circle, in the worst case. state := acquire(maxArea) edges := state.edges frontier := state.frontier defer release(state) frontier.Push(from.Integer(), 0) edges.Store(from.Integer(), encode(0, Direction(0))) // Starting point has no direction for !frontier.IsEmpty() { pCurr := frontier.Pop() current := unpackPoint(pCurr) // Decode the cost to reach the current point currentEncoded, _ := edges.Load(pCurr) currentCost, _ := decode(currentEncoded) // Check if we've reached the destination if current.Equal(to) { // Reconstruct the path path := make([]Point, 0, 64) path = append(path, current) for !current.Equal(from) { currentEncoded, _ := edges.Load(current.Integer()) _, dir := decode(currentEncoded) current = current.Move(oppositeDirection(dir)) path = append(path, current) } // Reverse the path to get from source to destination for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { path[i], path[j] = path[j], path[i] } return path, int(currentCost), true } // Explore neighbors m.Neighbors(current.X, current.Y, func(next Point, nextTile Tile[T]) { cNext := costOf(nextTile.Value()) if cNext == 0 { return // Blocked tile } nextCost := currentCost + uint32(cNext) pNext := next.Integer() existingEncoded, visited := edges.Load(pNext) existingCost, _ := decode(existingEncoded) // If we haven't visited this node or we found a better path if !visited || nextCost < existingCost { angle := angleOf(current, next) priority := nextCost + next.DistanceTo(to) // Store the edge and push to the frontier edges.Store(pNext, encode(nextCost, angle)) frontier.Push(pNext, priority) } }) } return nil, 0, false } // encode packs the cost and direction into a uint32 func encode(cost uint32, dir Direction) uint32 { return (cost << 4) | uint32(dir&0xF) } // decode unpacks the cost and direction from a uint32 func decode(value uint32) (cost uint32, dir Direction) { cost = value >> 4 dir = Direction(value & 0xF) return } // ----------------------------------------------------------------------------- type pathfinder struct { edges *intmap.Map frontier *frontier } var pathfinders = sync.Pool{ New: func() any { return &pathfinder{ edges: intmap.NewWithFill(32, .99), frontier: newFrontier(), } }, } // Acquires a new instance of a pathfinding state func acquire(capacity int) *pathfinder { v := pathfinders.Get().(*pathfinder) if v.edges.Capacity() < capacity { v.edges = intmap.NewWithFill(capacity, .99) } return v } // release releases a pathfinding state back to the pool func release(v *pathfinder) { v.edges.Clear() v.frontier.Reset() pathfinders.Put(v) } // ----------------------------------------------------------------------------- // frontier is a priority queue implementation that uses buckets to store // elements. Original implementation by Iskander Sharipov (https://github.com/quasilyte/pathing) type frontier struct { buckets [64][]uint32 mask uint64 } // newFrontier creates a new frontier priority queue func newFrontier() *frontier { h := &frontier{} for i := range &h.buckets { h.buckets[i] = make([]uint32, 0, 16) } return h } func (q *frontier) Reset() { buckets := &q.buckets // Reslice storage slices back. // To avoid traversing all len(q.buckets), // we have some offset to skip uninteresting (already empty) buckets. // We also stop when mask is 0 meaning all remaining buckets are empty too. // In other words, it would only touch slices between min and max non-empty priorities. mask := q.mask offset := uint(bits.TrailingZeros64(mask)) mask >>= offset i := offset for mask != 0 { if i < uint(len(buckets)) { buckets[i] = buckets[i][:0] } mask >>= 1 i++ } q.mask = 0 } func (q *frontier) IsEmpty() bool { return q.mask == 0 } func (q *frontier) Push(value, priority uint32) { // No bound checks since compiler knows that i will never exceed 64. // We also get a cool truncation of values above 64 to store them // in our biggest bucket. i := priority & 0b111111 q.buckets[i] = append(q.buckets[i], value) q.mask |= 1 << i } func (q *frontier) Pop() uint32 { buckets := &q.buckets // Using uints here and explicit len check to avoid the // implicitly inserted bound check. i := uint(bits.TrailingZeros64(q.mask)) if i < uint(len(buckets)) { e := buckets[i][len(buckets[i])-1] buckets[i] = buckets[i][:len(buckets[i])-1] if len(buckets[i]) == 0 { q.mask &^= 1 << i } return e } // A queue is empty return 0 } ================================================ FILE: path_test.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "fmt" "image" "image/color" "image/png" "os" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestPath(t *testing.T) { m := mapFrom("9x9.png") path, dist, found := m.Path(At(1, 1), At(7, 7), costOf) assert.Equal(t, ` ......... .x . . .x ... .. .xxx . .. ...x . . . xxx . .....x... . xxx. .........`, plotPath(m, path)) fmt.Println(plotPath(m, path)) assert.Equal(t, 12, dist) assert.True(t, found) } func TestPathTiny(t *testing.T) { m := NewGrid(6, 6) path, dist, found := m.Path(At(0, 0), At(5, 5), costOf) assert.Equal(t, ` x x x x x xxxxxx`, plotPath(m, path)) assert.Equal(t, 10, dist) assert.True(t, found) } func TestDraw(t *testing.T) { m := mapFrom("9x9.png") out := drawGrid(m, NewRect(0, 0, 0, 0)) assert.NotNil(t, out) } /* BenchmarkPath/9x9-24 2856020 423.0 ns/op 256 B/op 1 allocs/op BenchmarkPath/300x300-24 1167 1006143 ns/op 3845 B/op 4 allocs/op BenchmarkPath/381x381-24 3150 371478 ns/op 12629 B/op 5 allocs/op BenchmarkPath/384x384-24 3178 374982 ns/op 7298 B/op 5 allocs/op BenchmarkPath/3069x3069-24 787 1459683 ns/op 106188 B/op 7 allocs/op BenchmarkPath/3072x3072-24 799 1552230 ns/op 104906 B/op 7 allocs/op BenchmarkPath/6144x6144-24 3099 381935 ns/op 12716 B/op 5 allocs/op */ func BenchmarkPath(b *testing.B) { b.Run("9x9", func(b *testing.B) { m := mapFrom("9x9.png") b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(1, 1), At(7, 7), costOf) } }) b.Run("300x300", func(b *testing.B) { m := mapFrom("300x300.png") b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(115, 20), At(160, 270), costOf) } }) b.Run("381x381", func(b *testing.B) { m := NewGrid(381, 381) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(0, 0), At(380, 380), costOf) } }) b.Run("384x384", func(b *testing.B) { m := NewGrid(384, 384) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(0, 0), At(380, 380), costOf) } }) b.Run("3069x3069", func(b *testing.B) { m := NewGrid(3069, 3069) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(0, 0), At(700, 700), costOf) } }) b.Run("3072x3072", func(b *testing.B) { m := NewGrid(3072, 3072) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(0, 0), At(700, 700), costOf) } }) b.Run("6144x6144", func(b *testing.B) { m := NewGrid(6144, 6144) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Path(At(0, 0), At(380, 380), costOf) } }) } /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkAround/3r-24 2080566 562.7 ns/op 0 B/op 0 allocs/op BenchmarkAround/5r-24 885582 1358 ns/op 0 B/op 0 allocs/op BenchmarkAround/10r-24 300672 3953 ns/op 0 B/op 0 allocs/op */ func BenchmarkAround(b *testing.B) { m := mapFrom("300x300.png") b.Run("3r", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Around(At(115, 20), 3, costOf, func(_ Point, _ Tile[string]) {}) } }) b.Run("5r", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Around(At(115, 20), 5, costOf, func(_ Point, _ Tile[string]) {}) } }) b.Run("10r", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { m.Around(At(115, 20), 10, costOf, func(_ Point, _ Tile[string]) {}) } }) } func TestAround(t *testing.T) { m := mapFrom("9x9.png") for i := 0; i < 3; i++ { var path []string m.Around(At(2, 2), 3, costOf, func(p Point, tile Tile[string]) { path = append(path, p.String()) }) assert.Equal(t, 10, len(path)) assert.ElementsMatch(t, []string{ "2,2", "2,1", "2,3", "1,2", "3,1", "1,1", "1,3", "3,3", "4,3", "3,4", }, path) } } func TestAroundMiss(t *testing.T) { m := mapFrom("9x9.png") m.Around(At(20, 20), 3, costOf, func(p Point, tile Tile[string]) { t.Fail() }) } /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkHeap-24 240228 5076 ns/op 6016 B/op 68 allocs/op */ func BenchmarkHeap(b *testing.B) { for i := 0; i < b.N; i++ { h := newFrontier() for j := 0; j < 128; j++ { h.Push(rand(j), 1) } for j := 0; j < 128*10; j++ { h.Push(rand(j), 1) h.Pop() } } } // very fast semi-random function func rand(i int) uint32 { i = i + 10000 i = i ^ (i << 16) i = (i >> 5) ^ i return uint32(i & 0xFF) } // ----------------------------------------------------------------------------- // Cost estimation function func costOf(tile Value) uint16 { if (tile)&1 != 0 { return 0 // Blocked } return 1 } // mapFrom creates a map from ASCII string func mapFrom(name string) *Grid[string] { f, err := os.Open("fixtures/" + name) defer f.Close() if err != nil { panic(err) } // Decode the image img, err := png.Decode(f) if err != nil { panic(err) } m := NewGrid(int16(img.Bounds().Dx()), int16(img.Bounds().Dy())) for y := int16(0); y < m.Size.Y; y++ { for x := int16(0); x < m.Size.X; x++ { //fmt.Printf("%+v %T\n", img.At(int(x), int(y)), img.At(int(x), int(y))) v := img.At(int(x), int(y)).(color.RGBA) switch v.R { case 255: case 0: m.WriteAt(x, y, Value(0xff)) } } } return m } // plotPath plots the path on ASCII map func plotPath(m *Grid[string], path []Point) string { out := make([][]byte, m.Size.Y) for i := range out { out[i] = make([]byte, m.Size.X) } m.Each(func(l Point, tile Tile[string]) { //println(l.String(), int(tile[0])) switch { case pointInPath(l, path): out[l.Y][l.X] = 'x' case tile.Value()&1 != 0: out[l.Y][l.X] = '.' default: out[l.Y][l.X] = ' ' } }) var sb strings.Builder for _, line := range out { sb.WriteByte('\n') sb.WriteString(string(line)) } return sb.String() } // pointInPath returns whether a point is part of a path or not func pointInPath(point Point, path []Point) bool { for _, p := range path { if p.Equal(point) { return true } } return false } // draw converts the map to a black and white image for debugging purposes. func drawGrid(m *Grid[string], rect Rect) image.Image { if rect.Max.X == 0 || rect.Max.Y == 0 { rect = NewRect(0, 0, m.Size.X, m.Size.Y) } size := rect.Size() output := image.NewRGBA(image.Rect(0, 0, int(size.X), int(size.Y))) m.Within(rect.Min, rect.Max, func(p Point, tile Tile[string]) { a := uint8(255) if tile.Value() == 1 { a = 0 } output.SetRGBA(int(p.X), int(p.Y), color.RGBA{a, a, a, 255}) }) return output } ================================================ FILE: point.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "fmt" "math" ) // ----------------------------------------------------------------------------- // Point represents a 2D coordinate. type Point struct { X int16 // X coordinate Y int16 // Y coordinate } func unpackPoint(v uint32) Point { return At(int16(v>>16), int16(v)) } // At creates a new point at a specified x,y coordinate. func At(x, y int16) Point { return Point{X: x, Y: y} } // String returns string representation of a point. func (p Point) String() string { return fmt.Sprintf("%v,%v", p.X, p.Y) } // Integer returns a packed 32-bit integer representation of a point. func (p Point) Integer() uint32 { return (uint32(p.X) << 16) | (uint32(p.Y) & 0xffff) } // Equal compares two points and returns true if they are equal. func (p Point) Equal(other Point) bool { return p.X == other.X && p.Y == other.Y } // Add adds two points together. func (p Point) Add(p2 Point) Point { return Point{p.X + p2.X, p.Y + p2.Y} } // Subtract subtracts the second point from the first. func (p Point) Subtract(p2 Point) Point { return Point{p.X - p2.X, p.Y - p2.Y} } // Multiply multiplies two points together. func (p Point) Multiply(p2 Point) Point { return Point{p.X * p2.X, p.Y * p2.Y} } // Divide divides the first point by the second. func (p Point) Divide(p2 Point) Point { return Point{p.X / p2.X, p.Y / p2.Y} } // MultiplyScalar multiplies the given point by the scalar. func (p Point) MultiplyScalar(s int16) Point { return Point{p.X * s, p.Y * s} } // DivideScalar divides the given point by the scalar. func (p Point) DivideScalar(s int16) Point { return Point{p.X / s, p.Y / s} } // Within checks if the point is within the specified bounding box. func (p Point) Within(nw, se Point) bool { return Rect{Min: nw, Max: se}.Contains(p) } // WithinRect checks if the point is within the specified bounding box. func (p Point) WithinRect(box Rect) bool { return box.Contains(p) } // WithinSize checks if the point is within the specified bounding box // which starts at 0,0 until the width/height provided. func (p Point) WithinSize(size Point) bool { return p.X >= 0 && p.Y >= 0 && p.X < size.X && p.Y < size.Y } // Move moves a point by one in the specified direction. func (p Point) Move(direction Direction) Point { return p.MoveBy(direction, 1) } // MoveBy moves a point by n in the specified direction. func (p Point) MoveBy(direction Direction, n int16) Point { switch direction { case North: return Point{p.X, p.Y - n} case NorthEast: return Point{p.X + n, p.Y - n} case East: return Point{p.X + n, p.Y} case SouthEast: return Point{p.X + n, p.Y + n} case South: return Point{p.X, p.Y + n} case SouthWest: return Point{p.X - n, p.Y + n} case West: return Point{p.X - n, p.Y} case NorthWest: return Point{p.X - n, p.Y - n} default: return p } } // DistanceTo calculates manhattan distance to the other point func (p Point) DistanceTo(other Point) uint32 { return abs(int32(p.X)-int32(other.X)) + abs(int32(p.Y)-int32(other.Y)) } // Angle calculates the angle between two points func (p Point) Angle(other Point) Direction { dx := float64(other.X - p.X) dy := float64(other.Y - p.Y) // Calculate the angle in radians angle := math.Atan2(dy, dx) alpha := angle + math.Pi/2 if alpha < 0 { alpha += 2 * math.Pi } // Map to 8 directions (0-7) return Direction(math.Round(alpha/(math.Pi/4))) % 8 } func abs(n int32) uint32 { if n < 0 { return uint32(-n) } return uint32(n) } // ----------------------------------------------------------------------------- // Rect represents a rectangle type Rect struct { Min Point // Top left point of the rectangle Max Point // Bottom right point of the rectangle } // NewRect creates a new rectangle // left,top,right,bottom correspond to x1,y1,x2,y2 func NewRect(left, top, right, bottom int16) Rect { return Rect{Min: At(left, top), Max: At(right, bottom)} } // Contains returns whether a point is within the rectangle or not. func (a Rect) Contains(p Point) bool { return a.Min.X <= p.X && p.X < a.Max.X && a.Min.Y <= p.Y && p.Y < a.Max.Y } // Intersects returns whether a rectangle intersects with another rectangle or not. func (a Rect) Intersects(b Rect) bool { 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 } // Size returns the size of the rectangle func (a *Rect) Size() Point { return Point{ X: a.Max.X - a.Min.X, Y: a.Max.Y - a.Min.Y, } } // IsZero returns true if the rectangle is zero-value func (a Rect) IsZero() bool { return a.Min.X == a.Max.X && a.Min.Y == a.Max.Y } // Difference calculates up to four non-overlapping regions in a that are not covered by b. // If there are fewer than four distinct regions, the remaining Rects will be zero-value. func (a Rect) Difference(b Rect) (result [4]Rect) { if b.Contains(a.Min) && b.Contains(a.Max) { return // Fully covered, return zero-value result } // Check for non-overlapping cases if !a.Intersects(b) { result[0] = a // No overlap, return A as is return } left := min(a.Min.X, b.Min.X) right := max(a.Max.X, b.Max.X) top := min(a.Min.Y, b.Min.Y) bottom := max(a.Max.Y, b.Max.Y) result[0].Min = Point{X: left, Y: top} result[0].Max = Point{X: right, Y: max(a.Min.Y, b.Min.Y)} result[1].Min = Point{X: left, Y: min(a.Max.Y, b.Max.Y)} result[1].Max = Point{X: right, Y: bottom} result[2].Min = Point{X: left, Y: top} result[2].Max = Point{X: max(a.Min.X, b.Min.X), Y: bottom} result[3].Min = Point{X: min(a.Max.X, b.Max.X), Y: top} result[3].Max = Point{X: right, Y: bottom} if result[0].Size().X == 0 || result[0].Size().Y == 0 { result[0] = Rect{} } if result[1].Size().X == 0 || result[1].Size().Y == 0 { result[1] = Rect{} } if result[2].Size().X == 0 || result[2].Size().Y == 0 { result[2] = Rect{} } if result[3].Size().X == 0 || result[3].Size().Y == 0 { result[3] = Rect{} } return } // Pack returns a packed representation of a rectangle func (a Rect) pack() uint64 { return uint64(a.Min.Integer())<<32 | uint64(a.Max.Integer()) } // Unpack returns a rectangle from a packed representation func unpackRect(v uint64) Rect { return Rect{ Min: unpackPoint(uint32(v >> 32)), Max: unpackPoint(uint32(v)), } } // ----------------------------------------------------------------------------- // Diretion represents a direction type Direction byte // Various directions const ( North Direction = iota NorthEast East SouthEast South SouthWest West NorthWest ) // String returns a string representation of a direction func (v Direction) String() string { switch v { case North: return "🡱N" case NorthEast: return "🡵NE" case East: return "🡲E" case SouthEast: return "🡶SE" case South: return "🡳S" case SouthWest: return "🡷SW" case West: return "🡰W" case NorthWest: return "🡴NW" default: return "" } } // Vector returns a direction vector with a given scale func (v Direction) Vector(scale int16) Point { return Point{}.MoveBy(v, scale) } // angleOf returns the direction from one point to another func angleOf(from, to Point) Direction { dx := to.X - from.X dy := to.Y - from.Y switch { case dx == 0 && dy == -1: return North case dx == 1 && dy == -1: return NorthEast case dx == 1 && dy == 0: return East case dx == 1 && dy == 1: return SouthEast case dx == 0 && dy == 1: return South case dx == -1 && dy == 1: return SouthWest case dx == -1 && dy == 0: return West case dx == -1 && dy == -1: return NorthWest default: return Direction(0) // Invalid direction } } // oppositeDirection returns the opposite of the given direction func oppositeDirection(dir Direction) Direction { return Direction((dir + 4) % 8) } ================================================ FILE: point_test.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "testing" "github.com/stretchr/testify/assert" ) /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkPoint/within-24 1000000000 0.09854 ns/op 0 B/op 0 allocs/op BenchmarkPoint/within-rect-24 1000000000 0.09966 ns/op 0 B/op 0 allocs/op */ func BenchmarkPoint(b *testing.B) { p := At(10, 20) b.Run("within", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { p.Within(At(0, 0), At(100, 100)) } }) b.Run("within-rect", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { p.WithinRect(NewRect(0, 0, 100, 100)) } }) } func TestPoint(t *testing.T) { p := At(10, 20) p2 := At(2, 2) assert.Equal(t, int16(10), p.X) assert.Equal(t, int16(20), p.Y) assert.Equal(t, uint32(0xa0014), p.Integer()) assert.Equal(t, At(-5, 5), unpackPoint(At(-5, 5).Integer())) assert.Equal(t, "10,20", p.String()) assert.True(t, p.Equal(At(10, 20))) assert.Equal(t, "20,40", p.MultiplyScalar(2).String()) assert.Equal(t, "5,10", p.DivideScalar(2).String()) assert.Equal(t, "12,22", p.Add(p2).String()) assert.Equal(t, "8,18", p.Subtract(p2).String()) assert.Equal(t, "20,40", p.Multiply(p2).String()) assert.Equal(t, "5,10", p.Divide(p2).String()) assert.True(t, p.Within(At(1, 1), At(20, 30))) assert.True(t, p.WithinRect(NewRect(1, 1, 20, 30))) assert.False(t, p.WithinSize(At(10, 20))) assert.True(t, p.WithinSize(At(20, 30))) } func TestIntersects(t *testing.T) { assert.True(t, NewRect(0, 0, 2, 2).Intersects(NewRect(1, 0, 3, 2))) assert.False(t, NewRect(0, 0, 2, 2).Intersects(NewRect(2, 0, 4, 2))) assert.False(t, NewRect(10, 10, 12, 12).Intersects(NewRect(9, 12, 11, 14))) } func TestDirection(t *testing.T) { for i := 0; i < 8; i++ { dir := Direction(i) assert.NotEmpty(t, dir.String()) } } func TestDirection_Empty(t *testing.T) { dir := Direction(9) assert.Empty(t, dir.String()) } func TestPointAngle(t *testing.T) { tests := []struct { name string from Point to Point expected Direction }{ // Cardinal directions from origin {"North", At(0, 0), At(0, -1), North}, {"East", At(0, 0), At(1, 0), East}, {"South", At(0, 0), At(0, 1), South}, {"West", At(0, 0), At(-1, 0), West}, // Diagonal directions from origin {"NorthEast", At(0, 0), At(1, -1), NorthEast}, {"SouthEast", At(0, 0), At(1, 1), SouthEast}, {"SouthWest", At(0, 0), At(-1, 1), SouthWest}, {"NorthWest", At(0, 0), At(-1, -1), NorthWest}, // Same point (math.Atan2(0,0) = 0, which maps to East after transformation) {"Same point", At(5, 5), At(5, 5), East}, // Non-origin starting points {"From 10,10 North", At(10, 10), At(10, 5), North}, {"From 10,10 East", At(10, 10), At(15, 10), East}, {"From 10,10 South", At(10, 10), At(10, 15), South}, {"From 10,10 West", At(10, 10), At(5, 10), West}, {"From 10,10 NorthEast", At(10, 10), At(15, 5), NorthEast}, {"From 10,10 SouthEast", At(10, 10), At(15, 15), SouthEast}, {"From 10,10 SouthWest", At(10, 10), At(5, 15), SouthWest}, {"From 10,10 NorthWest", At(10, 10), At(5, 5), NorthWest}, // Edge cases with larger distances {"Far North", At(0, 0), At(0, -100), North}, {"Far East", At(0, 0), At(100, 0), East}, {"Far South", At(0, 0), At(0, 100), South}, {"Far West", At(0, 0), At(-100, 0), West}, // Angles close to boundaries (testing rounding) {"Near North boundary", At(0, 0), At(1, -10), North}, {"Near NorthEast boundary", At(0, 0), At(10, -10), NorthEast}, {"Near East boundary", At(0, 0), At(10, -1), East}, {"Near SouthEast boundary", At(0, 0), At(10, 10), SouthEast}, // Negative coordinates {"Negative coords North", At(-5, -5), At(-5, -10), North}, {"Negative coords East", At(-5, -5), At(0, -5), East}, {"Negative coords South", At(-5, -5), At(-5, 0), South}, {"Negative coords West", At(-5, -5), At(-10, -5), West}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := tc.from.Angle(tc.to) assert.Equal(t, tc.expected, result, "Point %s to %s should be %s, got %s", tc.from.String(), tc.to.String(), tc.expected.String(), result.String()) }) } } func TestMove(t *testing.T) { tests := []struct { dir Direction out Point }{ {North, Point{X: 0, Y: -1}}, {South, Point{X: 0, Y: 1}}, {East, Point{X: 1, Y: 0}}, {West, Point{X: -1, Y: 0}}, {NorthEast, Point{X: 1, Y: -1}}, {NorthWest, Point{X: -1, Y: -1}}, {SouthEast, Point{X: 1, Y: 1}}, {SouthWest, Point{X: -1, Y: 1}}, {Direction(99), Point{}}, } for _, tc := range tests { assert.Equal(t, tc.out, Point{}.Move(tc.dir), tc.dir.String()) } } func TestContains(t *testing.T) { tests := map[Point]bool{ {X: 0, Y: 0}: true, {X: 1, Y: 0}: true, {X: 0, Y: 1}: true, {X: 1, Y: 1}: true, {X: 2, Y: 2}: false, {X: 3, Y: 3}: false, {X: 1, Y: 2}: false, {X: 2, Y: 1}: false, } for point, expect := range tests { r := NewRect(0, 0, 2, 2) assert.Equal(t, expect, r.Contains(point), point.String()) } } func TestDiff_Right(t *testing.T) { a := Rect{At(0, 0), At(2, 2)} b := Rect{At(1, 0), At(3, 2)} diff := a.Difference(b) assert.Equal(t, Rect{At(0, 0), At(1, 2)}, diff[2]) assert.Equal(t, Rect{At(2, 0), At(3, 2)}, diff[3]) } func TestDiff_Left(t *testing.T) { a := Rect{At(0, 0), At(2, 2)} b := Rect{At(-1, 0), At(1, 2)} diff := a.Difference(b) assert.Equal(t, Rect{At(-1, 0), At(0, 2)}, diff[2]) assert.Equal(t, Rect{At(1, 0), At(2, 2)}, diff[3]) } func TestDiff_Up(t *testing.T) { a := Rect{At(0, 0), At(2, 2)} b := Rect{At(0, -1), At(2, 1)} diff := a.Difference(b) assert.Equal(t, Rect{At(0, -1), At(2, 0)}, diff[0]) assert.Equal(t, Rect{At(0, 1), At(2, 2)}, diff[1]) } func TestDiff_Down(t *testing.T) { a := Rect{At(0, 0), At(2, 2)} b := Rect{At(0, 1), At(2, 3)} diff := a.Difference(b) assert.Equal(t, Rect{At(0, 0), At(2, 1)}, diff[0]) assert.Equal(t, Rect{At(0, 2), At(2, 3)}, diff[1]) } ================================================ FILE: store.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "compress/flate" "encoding/binary" "io" "os" "unsafe" "github.com/kelindar/iostream" ) const tileDataSize = int(unsafe.Sizeof([9]Value{})) // ---------------------------------- Stream ---------------------------------- // WriteTo writes the grid to a specific writer. func (m *Grid[T]) WriteTo(dst io.Writer) (n int64, err error) { p1 := At(0, 0) p2 := At(m.Size.X-1, m.Size.Y-1) // Write the viewport size w := iostream.NewWriter(dst) header := make([]byte, 8) binary.BigEndian.PutUint16(header[0:2], uint16(p1.X)) binary.BigEndian.PutUint16(header[2:4], uint16(p1.Y)) binary.BigEndian.PutUint16(header[4:6], uint16(p2.X)) binary.BigEndian.PutUint16(header[6:8], uint16(p2.Y)) if _, err := w.Write(header); err != nil { return w.Offset(), err } // Write the grid data m.pagesWithin(p1, p2, func(page *page[T]) { buffer := (*[tileDataSize]byte)(unsafe.Pointer(&page.tiles))[:] if _, err := w.Write(buffer); err != nil { return } }) return w.Offset(), nil } // ReadFrom reads the grid from the reader. func ReadFrom[T comparable](src io.Reader) (grid *Grid[T], err error) { r := iostream.NewReader(src) header := make([]byte, 8) if _, err := io.ReadFull(r, header); err != nil { return nil, err } // Read the size var view Rect view.Min.X = int16(binary.BigEndian.Uint16(header[0:2])) view.Min.Y = int16(binary.BigEndian.Uint16(header[2:4])) view.Max.X = int16(binary.BigEndian.Uint16(header[4:6])) view.Max.Y = int16(binary.BigEndian.Uint16(header[6:8])) // Allocate a new grid grid = NewGridOf[T](view.Max.X+1, view.Max.Y+1) buf := make([]byte, tileDataSize) grid.pagesWithin(view.Min, view.Max, func(page *page[T]) { if _, err = io.ReadFull(r, buf); err != nil { return } copy((*[tileDataSize]byte)(unsafe.Pointer(&page.tiles))[:], buf) }) return } // ---------------------------------- File ---------------------------------- // WriteFile writes the grid into a flate-compressed binary file. func (m *Grid[T]) WriteFile(filename string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() writer, err := flate.NewWriter(file, flate.BestSpeed) if err != nil { return err } // WriteTo the underlying writer defer writer.Close() _, err = m.WriteTo(writer) return err } // Restore restores the grid from the specified file. The grid must // be written using the corresponding WriteFile() method. func ReadFile[T comparable](filename string) (grid *Grid[T], err error) { if _, err := os.Stat(filename); os.IsNotExist(err) { return nil, os.ErrNotExist } // Otherwise, attempt to open the file and restore file, err := os.Open(filename) if err != nil { return nil, err } defer file.Close() return ReadFrom[T](flate.NewReader(file)) } ================================================ FILE: store_test.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "bytes" "compress/flate" "os" "testing" "github.com/stretchr/testify/assert" ) /* cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz BenchmarkStore/save-8 14455 81883 ns/op 8 B/op 1 allocs/op BenchmarkStore/read-8 2787 399699 ns/op 647421 B/op 7 allocs/op */ func BenchmarkStore(b *testing.B) { m := mapFrom("300x300.png") b.Run("save", func(b *testing.B) { out := bytes.NewBuffer(make([]byte, 0, 550000)) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { out.Reset() m.WriteTo(out) } }) b.Run("read", func(b *testing.B) { enc := new(bytes.Buffer) m.WriteTo(enc) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { ReadFrom[string](bytes.NewBuffer(enc.Bytes())) } }) } func TestSaveLoad(t *testing.T) { m := mapFrom("300x300.png") // Save the map enc := new(bytes.Buffer) n, err := m.WriteTo(enc) assert.NoError(t, err) assert.Equal(t, int64(360008), n) // Load the map back out, err := ReadFrom[string](enc) assert.NoError(t, err) assert.Equal(t, m.pages, out.pages) } func TestSaveLoadFlate(t *testing.T) { m := mapFrom("300x300.png") // Save the map output := new(bytes.Buffer) writer, err := flate.NewWriter(output, flate.BestSpeed) assert.NoError(t, err) n, err := m.WriteTo(writer) assert.NoError(t, writer.Close()) assert.NoError(t, err) assert.Equal(t, int64(360008), n) assert.Equal(t, int(16533), output.Len()) // Load the map back reader := flate.NewReader(output) out, err := ReadFrom[string](reader) assert.NoError(t, err) assert.Equal(t, m.pages, out.pages) } func TestSaveLoadFile(t *testing.T) { temp, err := os.CreateTemp("", "*") assert.NoError(t, err) defer os.Remove(temp.Name()) // Write a test map into temp file m := mapFrom("300x300.png") assert.NoError(t, m.WriteFile(temp.Name())) fi, _ := temp.Stat() assert.Equal(t, int64(16533), fi.Size()) // Read the map back out, err := ReadFile[string](temp.Name()) assert.NoError(t, err) assert.Equal(t, m.pages, out.pages) } ================================================ FILE: view.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "sync" "sync/atomic" ) // Observer represents a tile update Observer. type Observer[T comparable] interface { Viewport() Rect Resize(Rect, func(Point, Tile[T])) onUpdate(*Update[T]) } // ValueAt represents a tile and its value. type ValueAt struct { Point // The point of the tile Value // The value of the tile } // Update represents a tile update notification. type Update[T comparable] struct { Old ValueAt // Old tile + value New ValueAt // New tile + value Add T // An object was added to the tile Del T // An object was removed from the tile } var _ Observer[string] = (*View[string, string])(nil) // View represents a view which can monitor a collection of tiles. Type parameters // S and T are the state and tile types respectively. type View[S any, T comparable] struct { Grid *Grid[T] // The associated map Inbox chan Update[T] // The update inbox for the view State S // The state of the view rect atomic.Uint64 // The view box } // NewView creates a new view for a map with a given state. State can be anything // that is passed to the view and can be used to store additional information. func NewView[S any, T comparable](m *Grid[T], state S) *View[S, T] { v := &View[S, T]{ Grid: m, Inbox: make(chan Update[T], 32), State: state, } v.rect.Store(NewRect(-1, -1, -1, -1).pack()) return v } // Viewport returns the current viewport of the view. func (v *View[S, T]) Viewport() Rect { return unpackRect(v.rect.Load()) } // Resize resizes the viewport and notifies the observers of the changes. func (v *View[S, T]) Resize(view Rect, fn func(Point, Tile[T])) { grid := v.Grid prev := unpackRect(v.rect.Swap(view.pack())) for _, diff := range view.Difference(prev) { if diff.IsZero() { continue // Skip zero-value rectangles } grid.pagesWithin(diff.Min, diff.Max, func(page *page[T]) { r := page.Bounds() switch { // Page is now in view case view.Intersects(r) && !prev.Intersects(r): if grid.observers.Subscribe(page.point, v) { page.SetObserved(true) // Mark the page as being observed } // Page is no longer in view case !view.Intersects(r) && prev.Intersects(r): if grid.observers.Unsubscribe(page.point, v) { page.SetObserved(false) // Mark the page as not being observed } } // Callback for each new tile in the view if fn != nil { page.Each(v.Grid, func(p Point, tile Tile[T]) { if view.Contains(p) && !prev.Contains(p) { fn(p, tile) } }) } }) } } // MoveTo moves the viewport towards a particular direction. func (v *View[S, T]) MoveTo(angle Direction, distance int16, fn func(Point, Tile[T])) { p := angle.Vector(distance) r := v.Viewport() v.Resize(Rect{ Min: r.Min.Add(p), Max: r.Max.Add(p), }, fn) } // MoveBy moves the viewport towards a particular direction. func (v *View[S, T]) MoveBy(x, y int16, fn func(Point, Tile[T])) { r := v.Viewport() v.Resize(Rect{ Min: r.Min.Add(At(x, y)), Max: r.Max.Add(At(x, y)), }, fn) } // MoveAt moves the viewport to a specific coordinate. func (v *View[S, T]) MoveAt(nw Point, fn func(Point, Tile[T])) { r := v.Viewport() size := r.Max.Subtract(r.Min) v.Resize(Rect{ Min: nw, Max: nw.Add(size), }, fn) } // Each iterates over all of the tiles in the view. func (v *View[S, T]) Each(fn func(Point, Tile[T])) { r := v.Viewport() v.Grid.Within(r.Min, r.Max, fn) } // At returns the tile at a specified position. func (v *View[S, T]) At(x, y int16) (Tile[T], bool) { return v.Grid.At(x, y) } // WriteAt updates the entire tile at a specific coordinate. func (v *View[S, T]) WriteAt(x, y int16, tile Value) { v.Grid.WriteAt(x, y, tile) } // MergeAt updates the bits of tile at a specific coordinate. The bits are specified // by the mask. The bits that need to be updated should be flipped on in the mask. func (v *View[S, T]) MergeAt(x, y int16, tile, mask Value) { v.Grid.MaskAt(x, y, tile, mask) } // Close closes the view and unsubscribes from everything. func (v *View[S, T]) Close() error { r := v.Viewport() v.Grid.pagesWithin(r.Min, r.Max, func(page *page[T]) { if v.Grid.observers.Unsubscribe(page.point, v) { page.SetObserved(false) // Mark the page as not being observed } }) return nil } // onUpdate occurs when a tile has updated. func (v *View[S, T]) onUpdate(ev *Update[T]) { v.Inbox <- *ev // (copy) } // ----------------------------------------------------------------------------- // Pubsub represents a publish/subscribe layer for observers. type pubsub[T comparable] struct { m sync.Map // Concurrent map of observers tmp sync.Pool // Temporary observer sets for notifications } // Subscribe registers an event listener on a system func (p *pubsub[T]) Subscribe(page Point, sub Observer[T]) bool { if v, ok := p.m.Load(page.Integer()); ok { return v.(*observers[T]).Subscribe(sub) } // Slow path v, _ := p.m.LoadOrStore(page.Integer(), newObservers[T]()) return v.(*observers[T]).Subscribe(sub) } // Unsubscribe deregisters an event listener from a system func (p *pubsub[T]) Unsubscribe(page Point, sub Observer[T]) bool { if v, ok := p.m.Load(page.Integer()); ok { return v.(*observers[T]).Unsubscribe(sub) } return false } // Notify notifies listeners of an update that happened. func (p *pubsub[T]) Notify1(ev *Update[T], page Point) { p.Each1(func(sub Observer[T]) { viewport := sub.Viewport() if viewport.Contains(ev.New.Point) || viewport.Contains(ev.Old.Point) { sub.onUpdate(ev) } }, page) } // Notify notifies listeners of an update that happened. func (p *pubsub[T]) Notify2(ev *Update[T], pages [2]Point) { p.Each2(func(sub Observer[T]) { viewport := sub.Viewport() if viewport.Contains(ev.New.Point) || viewport.Contains(ev.Old.Point) { sub.onUpdate(ev) } }, pages) } // Each iterates over each observer in a page func (p *pubsub[T]) Each1(fn func(sub Observer[T]), page Point) { if v, ok := p.m.Load(page.Integer()); ok { v.(*observers[T]).Each(func(sub Observer[T]) { fn(sub) }) } } // Each2 iterates over each observer in a page func (p *pubsub[T]) Each2(fn func(sub Observer[T]), pages [2]Point) { targets := p.tmp.Get().(map[Observer[T]]struct{}) clear(targets) defer p.tmp.Put(targets) // Collect all observers from all pages for _, page := range pages { if v, ok := p.m.Load(page.Integer()); ok { v.(*observers[T]).Each(func(sub Observer[T]) { targets[sub] = struct{}{} }) } } // Invoke the callback for each observer, once for sub := range targets { fn(sub) } } // ----------------------------------------------------------------------------- // Observers represents a change notifier which notifies the subscribers when // a specific tile is updated. type observers[T comparable] struct { sync.Mutex subs []Observer[T] } // newObservers creates a new instance of an change observer. func newObservers[T comparable]() *observers[T] { return &observers[T]{ subs: make([]Observer[T], 0, 8), } } // Each iterates over each observer func (s *observers[T]) Each(fn func(sub Observer[T])) { if s == nil { return } s.Lock() defer s.Unlock() for _, sub := range s.subs { fn(sub) } } // Subscribe registers an event listener on a system func (s *observers[T]) Subscribe(sub Observer[T]) bool { s.Lock() defer s.Unlock() s.subs = append(s.subs, sub) return len(s.subs) > 0 // At least one } // Unsubscribe deregisters an event listener from a system func (s *observers[T]) Unsubscribe(sub Observer[T]) bool { s.Lock() defer s.Unlock() clean := s.subs[:0] for _, o := range s.subs { if o != sub { clean = append(clean, o) } } s.subs = clean return len(s.subs) == 0 } ================================================ FILE: view_test.go ================================================ // Copyright (c) Roman Atachiants and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. package tile import ( "testing" "unsafe" "github.com/stretchr/testify/assert" ) /* cpu: 13th Gen Intel(R) Core(TM) i7-13700K BenchmarkView/write-24 9540012 125.0 ns/op 48 B/op 1 allocs/op BenchmarkView/move-24 16141 74408 ns/op 0 B/op 0 allocs/op */ func BenchmarkView(b *testing.B) { m := mapFrom("300x300.png") v := NewView(m, "view 1") v.Resize(NewRect(100, 0, 200, 100), nil) go func() { for range v.Inbox { } }() b.Run("write", func(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { v.WriteAt(152, 52, Value(0)) } }) b.Run("move", func(b *testing.B) { locs := []Point{ At(100, 0), At(200, 100), } b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { v.MoveAt(locs[n%2], nil) } }) } func TestView(t *testing.T) { m := mapFrom("300x300.png") // Create a new view c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(100, 0, 200, 100), c.count) assert.NotNil(t, v) assert.Equal(t, 10000, int(c)) // Resize to 10x10 c = counter(0) v.Resize(NewRect(0, 0, 10, 10), c.count) assert.Equal(t, 100, int(c)) // Move down-right c = counter(0) v.MoveBy(2, 2, c.count) assert.Equal(t, 48, int(c)) // Move at location c = counter(0) v.MoveAt(At(4, 4), c.count) assert.Equal(t, 48, int(c)) // Each c = counter(0) v.Each(c.count) assert.Equal(t, 100, int(c)) // Update a tile in view cursor, _ := v.At(5, 5) before := cursor.Value() v.WriteAt(5, 5, Value(55)) update := <-v.Inbox assert.Equal(t, At(5, 5), update.New.Point) assert.NotEqual(t, before, update.New) // Merge a tile in view, but with zero mask (won't do anything) cursor, _ = v.At(5, 5) before = cursor.Value() v.MergeAt(5, 5, Value(66), Value(0)) // zero mask update = <-v.Inbox assert.Equal(t, At(5, 5), update.New.Point) assert.Equal(t, before, update.New.Value) // Close the view assert.NoError(t, v.Close()) v.WriteAt(5, 5, Value(66)) assert.Equal(t, 0, len(v.Inbox)) } func TestUpdates_Simple(t *testing.T) { m := mapFrom("300x300.png") c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(0, 0, 10, 10), c.count) assert.NotNil(t, v) assert.Equal(t, 100, int(c)) // Update a tile in view cursor, _ := v.At(5, 5) cursor.Write(Value(0xF0)) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), }, New: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, }, <-v.Inbox) // Add an object to an observed tile cursor.Add("A") assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, New: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, Add: "A", }, <-v.Inbox) // Delete an object from an observed tile cursor.Del("A") assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, New: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, Del: "A", }, <-v.Inbox) // Mask a tile in view cursor.Mask(0xFF, 0x0F) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), Value: Value(0xF0), }, New: ValueAt{ Point: At(5, 5), Value: Value(0xFF), }, }, <-v.Inbox) // Merge a tile in view cursor.Merge(func(v Value) Value { return 0xAA }) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), Value: Value(0xFF), }, New: ValueAt{ Point: At(5, 5), Value: Value(0xAA), }, }, <-v.Inbox) } func TestMove_Within(t *testing.T) { m := mapFrom("300x300.png") c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(0, 0, 10, 10), c.count) // Add an object to an observed tile. This should only fire once since // both the old and new states are the observed by the view. cursor, _ := v.At(5, 5) cursor.Move("A", At(6, 6)) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), }, New: ValueAt{ Point: At(6, 6), }, Del: "A", Add: "A", }, <-v.Inbox) } func TestMove_Incoming(t *testing.T) { m := mapFrom("300x300.png") c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(0, 0, 10, 10), c.count) // Add an object to an observed tile from outside the view. cursor, _ := v.At(20, 20) cursor.Move("A", At(5, 5)) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(20, 20), }, New: ValueAt{ Point: At(5, 5), }, Del: "A", Add: "A", }, <-v.Inbox) } func TestMove_Outgoing(t *testing.T) { m := mapFrom("300x300.png") c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(0, 0, 10, 10), c.count) // Move an object from an observed tile outside of the view. cursor, _ := v.At(5, 5) cursor.Move("A", At(20, 20)) assert.Equal(t, Update[string]{ Old: ValueAt{ Point: At(5, 5), }, New: ValueAt{ Point: At(20, 20), }, Del: "A", Add: "A", }, <-v.Inbox) } func TestView_MoveTo(t *testing.T) { m := mapFrom("300x300.png") // Create a new view c := counter(0) v := NewView(m, "view 1") v.Resize(NewRect(10, 10, 12, 12), c.count) assert.NotNil(t, v) assert.Equal(t, 4, int(c)) assert.Equal(t, 9, countObservers(m)) const distance = 10 assert.Equal(t, 1, countObserversAt(m, 10, 10)) for i := 0; i < distance; i++ { v.MoveTo(East, 1, c.count) } assert.Equal(t, 0, countObserversAt(m, 10, 10)) for i := 0; i < distance; i++ { v.MoveTo(South, 1, c.count) } assert.Equal(t, 0, countObserversAt(m, 10, 10)) for i := 0; i < distance; i++ { v.MoveTo(West, 1, c.count) } assert.Equal(t, 0, countObserversAt(m, 10, 10)) for i := 0; i < distance; i++ { v.MoveTo(North, 1, c.count) } // Start should have the observer attached assert.Equal(t, 1, countObserversAt(m, 10, 10)) assert.Equal(t, 0, countObserversAt(m, 100, 100)) // Count the number of observers, should be the same as before assert.Equal(t, 9, countObservers(m)) assert.NoError(t, v.Close()) } func TestView_Updates(t *testing.T) { m := mapFrom("300x300.png") v := NewView(m, "view 1") v.Resize(NewRect(10, 10, 15, 15), nil) move := func(x1, y1, x2, y2 int16) { at, _ := m.At(x1, y1) at.Move("A", At(x2, y2)) assert.Equal(t, Update[string]{ Old: ValueAt{Point: At(x1, y1)}, New: ValueAt{Point: At(x2, y2)}, Del: "A", Add: "A", }, <-v.Inbox) } move(9, 12, 10, 12) // Enter from left edge move(10, 12, 9, 12) // Exit to left edge move(15, 12, 14, 12) // Enter from right edge move(14, 12, 15, 12) // Exit to right edge move(12, 9, 12, 10) // Enter from top edge move(12, 10, 12, 9) // Exit to top edge move(12, 15, 12, 14) // Enter from bottom edge move(12, 14, 12, 15) // Exit to bottom edge move(9, 9, 10, 10) // Enter from top-left diagonal move(10, 10, 9, 9) // Exit to top-left diagonal move(15, 9, 14, 10) // Enter from top-right diagonal move(14, 10, 15, 9) // Exit to top-right diagonal move(9, 15, 10, 14) // Enter from bottom-left diagonal move(10, 14, 9, 15) // Exit to bottom-left diagonal move(15, 15, 14, 14) // Enter from bottom-right diagonal move(14, 14, 15, 15) // Exit to bottom-right diagonal assert.NoError(t, v.Close()) } func TestSizeUpdate(t *testing.T) { assert.Equal(t, 24, int(unsafe.Sizeof(Update[uint32]{}))) } // ---------------------------------- Mocks ---------------------------------- func countObserversAt(m *Grid[string], x, y int16) (count int) { start, _ := m.At(x, y) start.Observers(func(view Observer[string]) { count++ }) return count } func countObservers(m *Grid[string]) int { var observers int m.Each(func(p Point, t Tile[string]) { if t.IsObserved() { observers++ } }) return observers } type fakeView[T comparable] func(*Update[T]) func (f fakeView[T]) Viewport() Rect { return Rect{} } func (f fakeView[T]) Resize(r Rect, fn func(Point, Tile[T])) { // Do nothing } func (f fakeView[T]) onUpdate(e *Update[T]) { f(e) } type counter int func (c *counter) count(p Point, tile Tile[string]) { *c++ }