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
<p align="center">
<img width="200" height="100" src="./.github/logo.png">
<br>
<img src="https://img.shields.io/github/go-mod/go-version/kelindar/tile" alt="Go Version">
<a href="https://pkg.go.dev/github.com/kelindar/tile"><img src="https://pkg.go.dev/badge/github.com/kelindar/tile" alt="PkgGoDev"></a>
<a href="https://goreportcard.com/report/github.com/kelindar/tile"><img src="https://goreportcard.com/badge/github.com/kelindar/tile" alt="Go Report Card"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License"></a>
<a href="https://coveralls.io/github/kelindar/tile"><img src="https://coveralls.io/repos/github/kelindar/tile/badge.svg" alt="Coverage"></a>
</p>
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++
}
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
SYMBOL INDEX (191 symbols across 10 files)
FILE: grid.go
type Grid (line 12) | type Grid struct
function NewGrid (line 22) | func NewGrid(width, height int16) *Grid[string] {
function NewGridOf (line 28) | func NewGridOf[T comparable](width, height int16) *Grid[T] {
method Each (line 57) | func (m *Grid[T]) Each(fn func(Point, Tile[T])) {
method Within (line 66) | func (m *Grid[T]) Within(nw, se Point, fn func(Point, Tile[T])) {
method pagesWithin (line 78) | func (m *Grid[T]) pagesWithin(nw, se Point, fn func(*page[T])) {
method At (line 91) | func (m *Grid[T]) At(x, y int16) (Tile[T], bool) {
method WriteAt (line 100) | func (m *Grid[T]) WriteAt(x, y int16, tile Value) {
method MaskAt (line 108) | func (m *Grid[T]) MaskAt(x, y int16, tile, mask Value) {
method MergeAt (line 115) | func (m *Grid[T]) MergeAt(x, y int16, merge func(Value) Value) {
method Neighbors (line 122) | func (m *Grid[T]) Neighbors(x, y int16, fn func(Point, Tile[T])) {
method pageAt (line 154) | func (m *Grid[T]) pageAt(x, y int16) *page[T] {
type page (line 174) | type page struct
method tileAt (line 183) | func (p *page[T]) tileAt(idx uint8) Value {
method IsObserved (line 188) | func (p *page[T]) IsObserved() bool {
method Bounds (line 193) | func (p *page[T]) Bounds() Rect {
method At (line 198) | func (p *page[T]) At(grid *Grid[T], x, y int16) Tile[T] {
method Each (line 203) | func (p *page[T]) Each(grid *Grid[T], fn func(Point, Tile[T])) {
method SetObserved (line 217) | func (p *page[T]) SetObserved(observed bool) {
method Lock (line 236) | func (p *page[T]) Lock() {
method Unlock (line 242) | func (p *page[T]) Unlock() {
method writeTile (line 249) | func (p *page[T]) writeTile(grid *Grid[T], idx uint8, after Value) {
method mergeTile (line 272) | func (p *page[T]) mergeTile(grid *Grid[T], idx uint8, fn func(Value) Val...
method addObject (line 302) | func (p *page[T]) addObject(idx uint8, object T) (value uint32) {
method delObject (line 318) | func (p *page[T]) delObject(idx uint8, object T) (value uint32) {
type Tile (line 331) | type Tile struct
method Count (line 338) | func (t Tile[T]) Count() (count int) {
method Point (line 350) | func (t Tile[T]) Point() Point {
method Value (line 355) | func (t Tile[T]) Value() Value {
method Range (line 360) | func (t Tile[T]) Range(fn func(T) error) error {
method IsObserved (line 374) | func (t Tile[T]) IsObserved() bool {
method Observers (line 379) | func (t Tile[T]) Observers(fn func(view Observer[T])) {
method Add (line 392) | func (t Tile[T]) Add(v T) {
method Del (line 413) | func (t Tile[T]) Del(v T) {
method Move (line 434) | func (t Tile[T]) Move(v T, dst Point) bool {
method Write (line 476) | func (t Tile[T]) Write(tile Value) {
method Merge (line 481) | func (t Tile[T]) Merge(merge func(Value) Value) Value {
method Mask (line 487) | func (t Tile[T]) Mask(tile, mask Value) Value {
function pointOf (line 494) | func pointOf(page Point, idx uint8) Point {
FILE: grid_test.go
function BenchmarkGrid (line 26) | func BenchmarkGrid(b *testing.B) {
function BenchmarkState (line 108) | func BenchmarkState(b *testing.B) {
function TestPageSize (line 146) | func TestPageSize(t *testing.T) {
function TestWithin (line 152) | func TestWithin(t *testing.T) {
function TestWithinCorner (line 168) | func TestWithinCorner(t *testing.T) {
function TestWithinXY (line 182) | func TestWithinXY(t *testing.T) {
function TestWithinOneSide (line 186) | func TestWithinOneSide(t *testing.T) {
function TestWithinInvalid (line 201) | func TestWithinInvalid(t *testing.T) {
function TestEach (line 210) | func TestEach(t *testing.T) {
function TestNeighbors (line 231) | func TestNeighbors(t *testing.T) {
function TestAt (line 260) | func TestAt(t *testing.T) {
function TestUpdate (line 283) | func TestUpdate(t *testing.T) {
function TestState (line 309) | func TestState(t *testing.T) {
function TestStateRangeErr (line 328) | func TestStateRangeErr(t *testing.T) {
function TestPointOf (line 341) | func TestPointOf(t *testing.T) {
function TestConcurrentMerge (line 375) | func TestConcurrentMerge(t *testing.T) {
FILE: path.go
type edge (line 17) | type edge struct
method Around (line 23) | func (m *Grid[T]) Around(from Point, distance uint32, costOf costFn, fn ...
method Path (line 69) | func (m *Grid[T]) Path(from, to Point, costOf costFn) ([]Point, int, boo...
function encode (line 141) | func encode(cost uint32, dir Direction) uint32 {
function decode (line 146) | func decode(value uint32) (cost uint32, dir Direction) {
type pathfinder (line 154) | type pathfinder struct
function acquire (line 169) | func acquire(capacity int) *pathfinder {
function release (line 179) | func release(v *pathfinder) {
type frontier (line 189) | type frontier struct
method Reset (line 203) | func (q *frontier) Reset() {
method IsEmpty (line 226) | func (q *frontier) IsEmpty() bool {
method Push (line 230) | func (q *frontier) Push(value, priority uint32) {
method Pop (line 239) | func (q *frontier) Pop() uint32 {
function newFrontier (line 195) | func newFrontier() *frontier {
FILE: path_test.go
function TestPath (line 18) | func TestPath(t *testing.T) {
function TestPathTiny (line 37) | func TestPathTiny(t *testing.T) {
function TestDraw (line 51) | func TestDraw(t *testing.T) {
function BenchmarkPath (line 66) | func BenchmarkPath(b *testing.B) {
function BenchmarkAround (line 137) | func BenchmarkAround(b *testing.B) {
function TestAround (line 164) | func TestAround(t *testing.T) {
function TestAroundMiss (line 180) | func TestAroundMiss(t *testing.T) {
function BenchmarkHeap (line 191) | func BenchmarkHeap(b *testing.B) {
function rand (line 205) | func rand(i int) uint32 {
function costOf (line 215) | func costOf(tile Value) uint16 {
function mapFrom (line 223) | func mapFrom(name string) *Grid[string] {
function plotPath (line 253) | func plotPath(m *Grid[string], path []Point) string {
function pointInPath (line 280) | func pointInPath(point Point, path []Point) bool {
function drawGrid (line 290) | func drawGrid(m *Grid[string], rect Rect) image.Image {
FILE: point.go
type Point (line 14) | type Point struct
method String (line 29) | func (p Point) String() string {
method Integer (line 34) | func (p Point) Integer() uint32 {
method Equal (line 39) | func (p Point) Equal(other Point) bool {
method Add (line 44) | func (p Point) Add(p2 Point) Point {
method Subtract (line 49) | func (p Point) Subtract(p2 Point) Point {
method Multiply (line 54) | func (p Point) Multiply(p2 Point) Point {
method Divide (line 59) | func (p Point) Divide(p2 Point) Point {
method MultiplyScalar (line 64) | func (p Point) MultiplyScalar(s int16) Point {
method DivideScalar (line 69) | func (p Point) DivideScalar(s int16) Point {
method Within (line 74) | func (p Point) Within(nw, se Point) bool {
method WithinRect (line 79) | func (p Point) WithinRect(box Rect) bool {
method WithinSize (line 85) | func (p Point) WithinSize(size Point) bool {
method Move (line 90) | func (p Point) Move(direction Direction) Point {
method MoveBy (line 95) | func (p Point) MoveBy(direction Direction, n int16) Point {
method DistanceTo (line 119) | func (p Point) DistanceTo(other Point) uint32 {
method Angle (line 124) | func (p Point) Angle(other Point) Direction {
function unpackPoint (line 19) | func unpackPoint(v uint32) Point {
function At (line 24) | func At(x, y int16) Point {
function abs (line 139) | func abs(n int32) uint32 {
type Rect (line 149) | type Rect struct
method Contains (line 161) | func (a Rect) Contains(p Point) bool {
method Intersects (line 166) | func (a Rect) Intersects(b Rect) bool {
method Size (line 171) | func (a *Rect) Size() Point {
method IsZero (line 179) | func (a Rect) IsZero() bool {
method Difference (line 185) | func (a Rect) Difference(b Rect) (result [4]Rect) {
method pack (line 230) | func (a Rect) pack() uint64 {
function NewRect (line 156) | func NewRect(left, top, right, bottom int16) Rect {
function unpackRect (line 235) | func unpackRect(v uint64) Rect {
type Direction (line 245) | type Direction
method String (line 260) | func (v Direction) String() string {
method Vector (line 284) | func (v Direction) Vector(scale int16) Point {
constant North (line 249) | North Direction = iota
constant NorthEast (line 250) | NorthEast
constant East (line 251) | East
constant SouthEast (line 252) | SouthEast
constant South (line 253) | South
constant SouthWest (line 254) | SouthWest
constant West (line 255) | West
constant NorthWest (line 256) | NorthWest
function angleOf (line 289) | func angleOf(from, to Point) Direction {
function oppositeDirection (line 316) | func oppositeDirection(dir Direction) Direction {
FILE: point_test.go
function BenchmarkPoint (line 17) | func BenchmarkPoint(b *testing.B) {
function TestPoint (line 36) | func TestPoint(t *testing.T) {
function TestIntersects (line 58) | func TestIntersects(t *testing.T) {
function TestDirection (line 64) | func TestDirection(t *testing.T) {
function TestDirection_Empty (line 71) | func TestDirection_Empty(t *testing.T) {
function TestPointAngle (line 76) | func TestPointAngle(t *testing.T) {
function TestMove (line 137) | func TestMove(t *testing.T) {
function TestContains (line 158) | func TestContains(t *testing.T) {
function TestDiff_Right (line 176) | func TestDiff_Right(t *testing.T) {
function TestDiff_Left (line 185) | func TestDiff_Left(t *testing.T) {
function TestDiff_Up (line 194) | func TestDiff_Up(t *testing.T) {
function TestDiff_Down (line 203) | func TestDiff_Down(t *testing.T) {
FILE: store.go
constant tileDataSize (line 16) | tileDataSize = int(unsafe.Sizeof([9]Value{}))
method WriteTo (line 21) | func (m *Grid[T]) WriteTo(dst io.Writer) (n int64, err error) {
function ReadFrom (line 47) | func ReadFrom[T comparable](src io.Reader) (grid *Grid[T], err error) {
method WriteFile (line 77) | func (m *Grid[T]) WriteFile(filename string) error {
function ReadFile (line 97) | func ReadFile[T comparable](filename string) (grid *Grid[T], err error) {
FILE: store_test.go
function BenchmarkStore (line 20) | func BenchmarkStore(b *testing.B) {
function TestSaveLoad (line 47) | func TestSaveLoad(t *testing.T) {
function TestSaveLoadFlate (line 62) | func TestSaveLoadFlate(t *testing.T) {
function TestSaveLoadFile (line 83) | func TestSaveLoadFile(t *testing.T) {
FILE: view.go
type Observer (line 12) | type Observer interface
type ValueAt (line 19) | type ValueAt struct
type Update (line 25) | type Update struct
type View (line 36) | type View struct
function NewView (line 45) | func NewView[S any, T comparable](m *Grid[T], state S) *View[S, T] {
method Viewport (line 56) | func (v *View[S, T]) Viewport() Rect {
method Resize (line 61) | func (v *View[S, T]) Resize(view Rect, fn func(Point, Tile[T])) {
method MoveTo (line 100) | func (v *View[S, T]) MoveTo(angle Direction, distance int16, fn func(Poi...
method MoveBy (line 110) | func (v *View[S, T]) MoveBy(x, y int16, fn func(Point, Tile[T])) {
method MoveAt (line 119) | func (v *View[S, T]) MoveAt(nw Point, fn func(Point, Tile[T])) {
method Each (line 129) | func (v *View[S, T]) Each(fn func(Point, Tile[T])) {
method At (line 135) | func (v *View[S, T]) At(x, y int16) (Tile[T], bool) {
method WriteAt (line 140) | func (v *View[S, T]) WriteAt(x, y int16, tile Value) {
method MergeAt (line 146) | func (v *View[S, T]) MergeAt(x, y int16, tile, mask Value) {
method Close (line 151) | func (v *View[S, T]) Close() error {
method onUpdate (line 162) | func (v *View[S, T]) onUpdate(ev *Update[T]) {
type pubsub (line 169) | type pubsub struct
method Subscribe (line 175) | func (p *pubsub[T]) Subscribe(page Point, sub Observer[T]) bool {
method Unsubscribe (line 186) | func (p *pubsub[T]) Unsubscribe(page Point, sub Observer[T]) bool {
method Notify1 (line 194) | func (p *pubsub[T]) Notify1(ev *Update[T], page Point) {
method Notify2 (line 204) | func (p *pubsub[T]) Notify2(ev *Update[T], pages [2]Point) {
method Each1 (line 214) | func (p *pubsub[T]) Each1(fn func(sub Observer[T]), page Point) {
method Each2 (line 223) | func (p *pubsub[T]) Each2(fn func(sub Observer[T]), pages [2]Point) {
type observers (line 247) | type observers struct
function newObservers (line 253) | func newObservers[T comparable]() *observers[T] {
method Each (line 260) | func (s *observers[T]) Each(fn func(sub Observer[T])) {
method Subscribe (line 273) | func (s *observers[T]) Subscribe(sub Observer[T]) bool {
method Unsubscribe (line 281) | func (s *observers[T]) Unsubscribe(sub Observer[T]) bool {
FILE: view_test.go
function BenchmarkView (line 18) | func BenchmarkView(b *testing.B) {
function TestView (line 50) | func TestView(t *testing.T) {
function TestUpdates_Simple (line 102) | func TestUpdates_Simple(t *testing.T) {
function TestMove_Within (line 181) | func TestMove_Within(t *testing.T) {
function TestMove_Incoming (line 203) | func TestMove_Incoming(t *testing.T) {
function TestMove_Outgoing (line 224) | func TestMove_Outgoing(t *testing.T) {
function TestView_MoveTo (line 245) | func TestView_MoveTo(t *testing.T) {
function TestView_Updates (line 288) | func TestView_Updates(t *testing.T) {
function TestSizeUpdate (line 324) | func TestSizeUpdate(t *testing.T) {
function countObserversAt (line 330) | func countObserversAt(m *Grid[string], x, y int16) (count int) {
function countObservers (line 338) | func countObservers(m *Grid[string]) int {
type fakeView (line 348) | type fakeView
method Viewport (line 350) | func (f fakeView[T]) Viewport() Rect {
method Resize (line 354) | func (f fakeView[T]) Resize(r Rect, fn func(Point, Tile[T])) {
method onUpdate (line 358) | func (f fakeView[T]) onUpdate(e *Update[T]) {
type counter (line 362) | type counter
method count (line 364) | func (c *counter) count(p Point, tile Tile[string]) {
Condensed preview — 17 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (99K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 19,
"preview": "github: [kelindar]\n"
},
{
"path": ".github/workflows/test.yml",
"chars": 700,
"preview": "name: Test\r\non: [push, pull_request]\r\nenv:\r\n GITHUB_TOKEN: ${{ secrets.COVERALLS_TOKEN }}\r\n GO111MODULE: \"on\"\r\njobs:\r\n"
},
{
"path": ".gitignore",
"chars": 0,
"preview": ""
},
{
"path": "LICENSE",
"chars": 1074,
"preview": "MIT License\n\nCopyright (c) 2020 Roman Atachiants \n\nPermission is hereby granted, free of charge, to any person obtaining"
},
{
"path": "README.md",
"chars": 15303,
"preview": "# Tile: Data-Oriented 2D Grid Engine\n\n<p align=\"center\">\n <img width=\"200\" height=\"100\" src=\"./.github/logo.png\">\n "
},
{
"path": "go.mod",
"chars": 310,
"preview": "module github.com/kelindar/tile\n\ngo 1.25\n\nrequire (\n\tgithub.com/kelindar/intmap v1.5.0\n\tgithub.com/kelindar/iostream v1."
},
{
"path": "go.sum",
"chars": 1229,
"preview": "github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.m"
},
{
"path": "grid.go",
"chars": 12943,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "grid_test.go",
"chars": 9665,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\r\n// Licensed under the MIT license. See LICENSE"
},
{
"path": "path.go",
"chars": 6488,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "path_test.go",
"chars": 6931,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "point.go",
"chars": 7884,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "point_test.go",
"chars": 6131,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "store.go",
"chars": 3062,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\r\n// Licensed under the MIT license. See LICENSE"
},
{
"path": "store_test.go",
"chars": 2338,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\r\n// Licensed under the MIT license. See LICENSE"
},
{
"path": "view.go",
"chars": 7877,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\n// Licensed under the MIT license. See LICENSE "
},
{
"path": "view_test.go",
"chars": 8399,
"preview": "// Copyright (c) Roman Atachiants and contributors. All rights reserved.\r\n// Licensed under the MIT license. See LICENSE"
}
]
About this extraction
This page contains the full source code of the kelindar/tile GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 17 files (88.2 KB), approximately 28.5k tokens, and a symbol index with 191 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.