mirror of
https://github.com/OpenDiablo2/OpenDiablo2
synced 2024-12-26 12:06:24 -05:00
Pull go-astar code into the repo, improve perf (#411)
Copied go-astar into d2common/d2astar, made a few optimizations. Runs roughly 30% faster according to my benchmarking. Added a `maxCost` param to prevent searching the entire map for a path. This probably needs tweaked a bit, but follows the original game more closely. Co-authored-by: Nicholas Eden <neden@zigzagame.com>
This commit is contained in:
parent
fe0fa6f14d
commit
dd21809288
153
d2common/d2astar/README.md
Normal file
153
d2common/d2astar/README.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
D2 A*
|
||||||
|
========
|
||||||
|
|
||||||
|
**A\* pathfinding implementation for OpenDiablo2**
|
||||||
|
|
||||||
|
***Forked from [go-astar](https://github.com/beefsack/go-astar)***
|
||||||
|
|
||||||
|
Changes
|
||||||
|
-------
|
||||||
|
* Used [sync.Pool](https://golang.org/pkg/sync/#Pool) to reuse objects created during pathfinding. This improves performance by roughly 30% by reducing allocations.
|
||||||
|
* Added a check on the target for neighbors to identify if the user clicked an inaccessible area.
|
||||||
|
* Added a max cost to prevent searching the entire region for a path.
|
||||||
|
|
||||||
|
TODO
|
||||||
|
------
|
||||||
|
* Evaluate bi-directional A*, specifically if it would more quickly identify if the user clicked an in inaccessible area (such as an island).
|
||||||
|
|
||||||
|
|
||||||
|
The [A\* pathfinding algorithm](http://en.wikipedia.org/wiki/A*_search_algorithm) is a pathfinding algorithm noted for its performance and accuracy and is commonly used in game development. It can be used to find short paths for any weighted graph.
|
||||||
|
|
||||||
|
A fantastic overview of A\* can be found at [Amit Patel's Stanford website](http://theory.stanford.edu/~amitp/GameProgramming/AStarComparison.html).
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
|
||||||
|
The following crude examples were taken directly from the automated tests. Please see `path_test.go` for more examples.
|
||||||
|
|
||||||
|
### Key
|
||||||
|
|
||||||
|
* `.` - Plain (movement cost 1)
|
||||||
|
* `~` - River (movement cost 2)
|
||||||
|
* `M` - Mountain (movement cost 3)
|
||||||
|
* `X` - Blocker, unable to move through
|
||||||
|
* `F` - From / start position
|
||||||
|
* `T` - To / goal position
|
||||||
|
* `●` - Calculated path
|
||||||
|
|
||||||
|
### Straight line
|
||||||
|
|
||||||
|
```
|
||||||
|
.....~...... .....~......
|
||||||
|
.....MM..... .....MM.....
|
||||||
|
.F........T. -> .●●●●●●●●●●.
|
||||||
|
....MMM..... ....MMM.....
|
||||||
|
............ ............
|
||||||
|
```
|
||||||
|
|
||||||
|
### Around a mountain
|
||||||
|
|
||||||
|
```
|
||||||
|
.....~...... .....~......
|
||||||
|
.....MM..... .....MM.....
|
||||||
|
.F..MMMM..T. -> .●●●MMMM●●●.
|
||||||
|
....MMM..... ...●MMM●●...
|
||||||
|
............ ...●●●●●....
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocked path
|
||||||
|
|
||||||
|
```
|
||||||
|
............
|
||||||
|
.........XXX
|
||||||
|
.F.......XTX -> No path
|
||||||
|
.........XXX
|
||||||
|
............
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maze
|
||||||
|
|
||||||
|
```
|
||||||
|
FX.X........ ●X.X●●●●●●..
|
||||||
|
.X...XXXX.X. ●X●●●XXXX●X.
|
||||||
|
.X.X.X....X. -> ●X●X.X●●●●X.
|
||||||
|
...X.X.XXXXX ●●●X.X●XXXXX
|
||||||
|
.XX..X.....T .XX..X●●●●●●
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mountain climber
|
||||||
|
|
||||||
|
```
|
||||||
|
..F..M...... ..●●●●●●●●●.
|
||||||
|
.....MM..... .....MM...●.
|
||||||
|
....MMMM..T. -> ....MMMM..●.
|
||||||
|
....MMM..... ....MMM.....
|
||||||
|
............ ............
|
||||||
|
```
|
||||||
|
|
||||||
|
### River swimmer
|
||||||
|
|
||||||
|
```
|
||||||
|
.....~...... .....~......
|
||||||
|
.....~...... ....●●●.....
|
||||||
|
.F...X...T.. -> .●●●●X●●●●..
|
||||||
|
.....M...... .....M......
|
||||||
|
.....M...... .....M......
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
### Import the package
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/beefsack/go-astar"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implement Pather interface
|
||||||
|
|
||||||
|
An example implementation is done for the tests in `path_test.go` for the Tile type.
|
||||||
|
|
||||||
|
The `PathNeighbors` method should return a slice of the direct neighbors.
|
||||||
|
|
||||||
|
The `PathNeighborCost` method should calculate an exact movement cost for direct neighbors.
|
||||||
|
|
||||||
|
The `PathEstimatedCost` is a heuristic method for estimating the distance between arbitrary tiles. The examples in the test files use [Manhattan distance](http://en.wikipedia.org/wiki/Taxicab_geometry) to estimate orthogonal distance between tiles.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Tile struct{}
|
||||||
|
|
||||||
|
func (t *Tile) PathNeighbors() []astar.Pather {
|
||||||
|
return []astar.Pather{
|
||||||
|
t.Up(),
|
||||||
|
t.Right(),
|
||||||
|
t.Down(),
|
||||||
|
t.Left(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tile) PathNeighborCost(to astar.Pather) float64 {
|
||||||
|
return to.MovementCost
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tile) PathEstimatedCost(to astar.Pather) float64 {
|
||||||
|
return t.ManhattanDistance(to)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call Path function
|
||||||
|
|
||||||
|
```go
|
||||||
|
// t1 and t2 are *Tile objects from inside the world.
|
||||||
|
path, distance, found := astar.Path(t1, t2)
|
||||||
|
if !found {
|
||||||
|
log.Println("Could not find path")
|
||||||
|
}
|
||||||
|
// path is a slice of Pather objects which you can cast back to *Tile.
|
||||||
|
```
|
||||||
|
|
||||||
|
Authors
|
||||||
|
-------
|
||||||
|
|
||||||
|
Michael Alexander <beefsack@gmail.com>
|
||||||
|
Robin Ranjit Chauhan <robin@pathwayi.com>
|
154
d2common/d2astar/astar.go
Normal file
154
d2common/d2astar/astar.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/heap"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nodePool *sync.Pool
|
||||||
|
var nodeMapPool *sync.Pool
|
||||||
|
var priorityQueuePool *sync.Pool
|
||||||
|
func init() {
|
||||||
|
nodePool = &sync.Pool {
|
||||||
|
New: func()interface{} {
|
||||||
|
return &node{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeMapPool = &sync.Pool {
|
||||||
|
New: func()interface{} {
|
||||||
|
return make(nodeMap, 128)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
priorityQueuePool = &sync.Pool {
|
||||||
|
New: func()interface{} {
|
||||||
|
return priorityQueue{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// astar is an A* pathfinding implementation.
|
||||||
|
|
||||||
|
// Pather is an interface which allows A* searching on arbitrary objects which
|
||||||
|
// can represent a weighted graph.
|
||||||
|
type Pather interface {
|
||||||
|
// PathNeighbors returns the direct neighboring nodes of this node which
|
||||||
|
// can be pathed to.
|
||||||
|
PathNeighbors() []Pather
|
||||||
|
// PathNeighborCost calculates the exact movement cost to neighbor nodes.
|
||||||
|
PathNeighborCost(to Pather) float64
|
||||||
|
// PathEstimatedCost is a heuristic method for estimating movement costs
|
||||||
|
// between non-adjacent nodes.
|
||||||
|
PathEstimatedCost(to Pather) float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// node is a wrapper to store A* data for a Pather node.
|
||||||
|
type node struct {
|
||||||
|
pather Pather
|
||||||
|
cost float64
|
||||||
|
rank float64
|
||||||
|
parent *node
|
||||||
|
open bool
|
||||||
|
closed bool
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *node) reset () {
|
||||||
|
n.pather = nil
|
||||||
|
n.cost = 0
|
||||||
|
n.rank = 0
|
||||||
|
n.parent = nil
|
||||||
|
n.open = false
|
||||||
|
n.closed = false
|
||||||
|
n.index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeMap is a collection of nodes keyed by Pather nodes for quick reference.
|
||||||
|
type nodeMap map[Pather]*node
|
||||||
|
|
||||||
|
// get gets the Pather object wrapped in a node, instantiating if required.
|
||||||
|
func (nm nodeMap) get(p Pather) *node {
|
||||||
|
n, ok := nm[p]
|
||||||
|
if !ok {
|
||||||
|
n = nodePool.Get().(*node)
|
||||||
|
n.pather = p
|
||||||
|
nm[p] = n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path calculates a short path and the distance between the two Pather nodes.
|
||||||
|
//
|
||||||
|
// If no path is found, found will be false.
|
||||||
|
func Path(from, to Pather, maxCost float64) (path []Pather, distance float64, found bool) {
|
||||||
|
// Quick escape for inaccessible areas.
|
||||||
|
toNeighbors := to.PathNeighbors()
|
||||||
|
if len(toNeighbors) == 0 {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
nm := nodeMapPool.Get().(nodeMap)
|
||||||
|
nq := priorityQueuePool.Get().(priorityQueue)
|
||||||
|
defer func() {
|
||||||
|
for k, v := range nm {
|
||||||
|
v.reset()
|
||||||
|
nodePool.Put(v)
|
||||||
|
delete(nm, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
nq = nq[0:0]
|
||||||
|
nodeMapPool.Put(nm)
|
||||||
|
priorityQueuePool.Put(nq)
|
||||||
|
}()
|
||||||
|
|
||||||
|
heap.Init(&nq)
|
||||||
|
fromNode := nm.get(from)
|
||||||
|
fromNode.open = true
|
||||||
|
heap.Push(&nq, fromNode)
|
||||||
|
for {
|
||||||
|
if nq.Len() == 0 {
|
||||||
|
// There's no path, return found false.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := heap.Pop(&nq).(*node)
|
||||||
|
current.open = false
|
||||||
|
current.closed = true
|
||||||
|
|
||||||
|
if current == nm.get(to) {
|
||||||
|
// Found a path to the goal.
|
||||||
|
p := make([]Pather, 0, 16)
|
||||||
|
curr := current
|
||||||
|
for curr != nil {
|
||||||
|
p = append(p, curr.pather)
|
||||||
|
curr = curr.parent
|
||||||
|
}
|
||||||
|
return p, current.cost, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, neighbor := range current.pather.PathNeighbors() {
|
||||||
|
cost := current.cost + current.pather.PathNeighborCost(neighbor)
|
||||||
|
if cost > maxCost {
|
||||||
|
fmt.Println("Canceling path")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
neighborNode := nm.get(neighbor)
|
||||||
|
if cost < neighborNode.cost {
|
||||||
|
if neighborNode.open {
|
||||||
|
heap.Remove(&nq, neighborNode.index)
|
||||||
|
}
|
||||||
|
neighborNode.open = false
|
||||||
|
neighborNode.closed = false
|
||||||
|
}
|
||||||
|
if !neighborNode.open && !neighborNode.closed {
|
||||||
|
neighborNode.cost = cost
|
||||||
|
neighborNode.open = true
|
||||||
|
neighborNode.rank = cost + neighbor.PathEstimatedCost(to)
|
||||||
|
neighborNode.parent = current
|
||||||
|
heap.Push(&nq, neighborNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
d2common/d2astar/goreland_example.go
Normal file
96
d2common/d2astar/goreland_example.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
// goreland_example.go implements implements Pather for
|
||||||
|
// the sake of testing. This functionality forms the back end for
|
||||||
|
// goreland_test.go, and serves as an example for how to use A* for a graph.
|
||||||
|
|
||||||
|
|
||||||
|
// The Magical World of Goreland, is where Ted Stevens and Al Gore are from.
|
||||||
|
//
|
||||||
|
// It is composed of Big Trucks, and a Series of Tubes!
|
||||||
|
//
|
||||||
|
// Ok, it is basically just a Graph.
|
||||||
|
// Nodes are called "Trucks" and they have X, Y coordinates
|
||||||
|
// Edges are called "Tubes", they connect Trucks, and they have a cost
|
||||||
|
//
|
||||||
|
// The key differences between this example and the Tile world:
|
||||||
|
// 1) There is no grid. Trucks have arbitrary coordinates.
|
||||||
|
// 2) Edges are not implied by the grid positions. Instead edges are explicitly
|
||||||
|
// modelled as Tubes.
|
||||||
|
//
|
||||||
|
// The key similarities between this example and the Tile world:
|
||||||
|
// 1) They both use Manhattan distance as their heuristic
|
||||||
|
// 2) Both implement Pather
|
||||||
|
|
||||||
|
type Goreland struct {
|
||||||
|
// trucks map[int]*Truck // not needed really
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tube struct {
|
||||||
|
from *Truck
|
||||||
|
to *Truck
|
||||||
|
Cost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Truck is a Truck in a grid which implements Grapher.
|
||||||
|
type Truck struct {
|
||||||
|
|
||||||
|
// X and Y are the coordinates of the truck.
|
||||||
|
X, Y int
|
||||||
|
|
||||||
|
// array of tubes going to other trucks
|
||||||
|
out_to []Tube
|
||||||
|
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathNeighbors returns the neighbors of the Truck
|
||||||
|
func (t *Truck) PathNeighbors() []Pather {
|
||||||
|
|
||||||
|
neighbors := []Pather{}
|
||||||
|
|
||||||
|
for _, tube_element := range t.out_to {
|
||||||
|
neighbors = append(neighbors, Pather(tube_element.to))
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathNeighborCost returns the cost of the tube leading to Truck.
|
||||||
|
func (t *Truck) PathNeighborCost(to Pather) float64 {
|
||||||
|
|
||||||
|
for _, tube_element := range (t).out_to {
|
||||||
|
if Pather((tube_element.to)) == to {
|
||||||
|
return tube_element.Cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 10000000
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathEstimatedCost uses Manhattan distance to estimate orthogonal distance
|
||||||
|
// between non-adjacent nodes.
|
||||||
|
func (t *Truck) PathEstimatedCost(to Pather) float64 {
|
||||||
|
|
||||||
|
toT := to.(*Truck)
|
||||||
|
absX := toT.X - t.X
|
||||||
|
if absX < 0 {
|
||||||
|
absX = -absX
|
||||||
|
}
|
||||||
|
absY := toT.Y - t.Y
|
||||||
|
if absY < 0 {
|
||||||
|
absY = -absY
|
||||||
|
}
|
||||||
|
r := float64(absX + absY)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPath renders a path on top of a Goreland world.
|
||||||
|
func (w Goreland) RenderPath(path []Pather) string {
|
||||||
|
|
||||||
|
s := ""
|
||||||
|
for _, p := range path {
|
||||||
|
pT := p.(*Truck)
|
||||||
|
s = pT.label + " " + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
86
d2common/d2astar/goreland_test.go
Normal file
86
d2common/d2astar/goreland_test.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddTruck(x int, y int, label string) *Truck {
|
||||||
|
t1 := new(Truck)
|
||||||
|
t1.X = x
|
||||||
|
t1.Y = y
|
||||||
|
t1.label = label
|
||||||
|
return t1
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddTube(t1, t2 *Truck, cost float64) *Tube {
|
||||||
|
tube1 := new(Tube)
|
||||||
|
tube1.Cost = cost
|
||||||
|
tube1.from = t1
|
||||||
|
tube1.to = t2
|
||||||
|
|
||||||
|
t1.out_to = append(t1.out_to, *tube1)
|
||||||
|
t2.out_to = append(t2.out_to, *tube1)
|
||||||
|
|
||||||
|
return tube1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider a world with Nodes (Trucks) and Edges (Tubes), Edges each having a cost
|
||||||
|
//
|
||||||
|
// E
|
||||||
|
// /|
|
||||||
|
// / |
|
||||||
|
// S--M
|
||||||
|
//
|
||||||
|
// S=Start at (0,0)
|
||||||
|
// E=End at (1,1)
|
||||||
|
// M=Middle at (0,1)
|
||||||
|
//
|
||||||
|
// S-M and M-E are clean clear tubes. cost: 1
|
||||||
|
//
|
||||||
|
// S-E is either:
|
||||||
|
//
|
||||||
|
// 1) TestGraphPath_ShortDiagonal : diagonal is a nice clean clear Tube , cost: 1.9
|
||||||
|
// Solver should traverse the bridge.
|
||||||
|
// Expect solution: Start, End Total cost: 1.9
|
||||||
|
//
|
||||||
|
// 1) TestGraphPath_LongDiagonal : diagonal is a Tube plugged full of
|
||||||
|
// "enormous amounts of material"!, cost: 10000.
|
||||||
|
// Solver should avoid the plugged tube.
|
||||||
|
// Expect solution Start,Middle,End Total cost: 2.0
|
||||||
|
|
||||||
|
func createGorelandGraphPath_Diagonal(t *testing.T, diagonal_cost float64, expectedDist float64) {
|
||||||
|
|
||||||
|
world := new(Goreland)
|
||||||
|
|
||||||
|
tr_start := AddTruck(0, 0, "Start")
|
||||||
|
tr_mid := AddTruck(0, 1, "Middle")
|
||||||
|
tr_end := AddTruck(1, 1, "End")
|
||||||
|
|
||||||
|
AddTube(tr_start, tr_end, diagonal_cost)
|
||||||
|
AddTube(tr_start, tr_mid, 1)
|
||||||
|
AddTube(tr_mid, tr_end, 1)
|
||||||
|
|
||||||
|
t.Logf("Goreland. Diagonal cost: %v\n\n", diagonal_cost)
|
||||||
|
|
||||||
|
p, dist, found := Path(tr_start, tr_end, math.MaxFloat64)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Log("Could not find a path")
|
||||||
|
} else {
|
||||||
|
t.Logf("Resulting path\n%s", world.RenderPath(p))
|
||||||
|
}
|
||||||
|
if !found && expectedDist >= 0 {
|
||||||
|
t.Fatal("Could not find a path")
|
||||||
|
}
|
||||||
|
if found && dist != expectedDist {
|
||||||
|
t.Fatalf("Expected dist to be %v but got %v", expectedDist, dist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphPaths_ShortDiagonal(t *testing.T) {
|
||||||
|
createGorelandGraphPath_Diagonal(t, 1.9, 1.9)
|
||||||
|
}
|
||||||
|
func TestGraphPaths_LongDiagonal(t *testing.T) {
|
||||||
|
createGorelandGraphPath_Diagonal(t, 10000, 2.0)
|
||||||
|
}
|
132
d2common/d2astar/path_test.go
Normal file
132
d2common/d2astar/path_test.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
// path_test.go contains the high level tests without the testing
|
||||||
|
// implementation. testPath is used to check the calculated path distance is
|
||||||
|
// what we're expecting.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testPath takes a string encoded world, decodes it, calculates a path and
|
||||||
|
// checks the expected distance matches. An expectedDist of -1 expects that no
|
||||||
|
// path will be found.
|
||||||
|
func testPath(worldInput string, t *testing.T, expectedDist float64) {
|
||||||
|
world := ParseWorld(worldInput)
|
||||||
|
t.Logf("Input world\n%s", world.RenderPath([]Pather{}))
|
||||||
|
p, dist, found := Path(world.From(), world.To(), math.MaxFloat64)
|
||||||
|
if !found {
|
||||||
|
t.Log("Could not find a path")
|
||||||
|
} else {
|
||||||
|
t.Logf("Resulting path\n%s", world.RenderPath(p))
|
||||||
|
}
|
||||||
|
if !found && expectedDist >= 0 {
|
||||||
|
t.Fatal("Could not find a path")
|
||||||
|
}
|
||||||
|
if found && dist != expectedDist {
|
||||||
|
t.Fatalf("Expected dist to be %v but got %v", expectedDist, dist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStraightLine checks that having no obstacles results in a straight line
|
||||||
|
// path.
|
||||||
|
func TestStraightLine(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
.....~......
|
||||||
|
.....MM.....
|
||||||
|
.F........T.
|
||||||
|
....MMM.....
|
||||||
|
............
|
||||||
|
`, t, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPathAroundMountain checks that having a round mountain in the path
|
||||||
|
// results in a path around the mountain.
|
||||||
|
func TestPathAroundMountain(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
.....~......
|
||||||
|
.....MM.....
|
||||||
|
.F..MMMM..T.
|
||||||
|
....MMM.....
|
||||||
|
............
|
||||||
|
`, t, 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlocked checks that no path is returned when there is no possible path.
|
||||||
|
func TestBlocked(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
............
|
||||||
|
.........XXX
|
||||||
|
.F.......XTX
|
||||||
|
.........XXX
|
||||||
|
............
|
||||||
|
`, t, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaze checks that paths can double back on themselves to reach the goal.
|
||||||
|
func TestMaze(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
FX.X........
|
||||||
|
.X...XXXX.X.
|
||||||
|
.X.X.X....X.
|
||||||
|
...X.X.XXXXX
|
||||||
|
.XX..X.....T
|
||||||
|
`, t, 27)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMountainClimber checks that a path will choose to go over a mountain,
|
||||||
|
// which has a movement penalty of 3, if it's faster than going around the
|
||||||
|
// mountain.
|
||||||
|
func TestMountainClimber(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
..F..M......
|
||||||
|
.....MM.....
|
||||||
|
....MMMM..T.
|
||||||
|
....MMM.....
|
||||||
|
............
|
||||||
|
`, t, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRiverSwimmer checks that the path will prefer to cross a river, which
|
||||||
|
// has a movement penalty of 2, over a mountain which has a movement penalty of
|
||||||
|
// 3.
|
||||||
|
func TestRiverSwimmer(t *testing.T) {
|
||||||
|
testPath(`
|
||||||
|
.....~......
|
||||||
|
.....~......
|
||||||
|
.F...X...T..
|
||||||
|
.....M......
|
||||||
|
.....M......
|
||||||
|
`, t, 11)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkLarge(b *testing.B) {
|
||||||
|
world := ParseWorld(`
|
||||||
|
F............................~.................................................
|
||||||
|
.............................~.................................................
|
||||||
|
........M...........X........~.................................................
|
||||||
|
.......MMM.........X.........~~................................................
|
||||||
|
........MM........X...........~................................................
|
||||||
|
.......MM........X............~................................................
|
||||||
|
................X.............~................................................
|
||||||
|
...............X..............~~...............................................
|
||||||
|
..............X................~...............................................
|
||||||
|
.............X.................~...X...............~...........................
|
||||||
|
............X.......................X..............~...........................
|
||||||
|
...........X.........................X.............~...........................
|
||||||
|
..........X..................~........X............~...........................
|
||||||
|
.........X...................~.........X...........~...........................
|
||||||
|
.............................~..........X..........~...............XXXXXXXXXXXX
|
||||||
|
............................~............X..........~..............X...X...X...
|
||||||
|
............................~.............X.........~......MMM.....X.X.X.X.X.X.
|
||||||
|
............................~..............X........~......MM......X.X.X.X.X.X.
|
||||||
|
............................~...............X.......~....MMMM......X.X.X.X.X.X.
|
||||||
|
...........................~.................X.....~......MMM......X.X.X.X.X.X.
|
||||||
|
..............................................X....~.......MM......X.X.X.X.X.X.
|
||||||
|
...............................................X...~.......M.........X...X...XT
|
||||||
|
`)
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Path(world.From(), world.To(), math.MaxFloat64)
|
||||||
|
}
|
||||||
|
}
|
198
d2common/d2astar/pather_test.go
Normal file
198
d2common/d2astar/pather_test.go
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
// pather_test.go implements a basic world and tiles that implement Pather for
|
||||||
|
// the sake of testing. This functionality forms the back end for
|
||||||
|
// path_test.go, and serves as an example for how to use A* for a grid.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind* constants refer to tile kinds for input and output.
|
||||||
|
const (
|
||||||
|
// KindPlain (.) is a plain tile with a movement cost of 1.
|
||||||
|
KindPlain = iota
|
||||||
|
// KindRiver (~) is a river tile with a movement cost of 2.
|
||||||
|
KindRiver
|
||||||
|
// KindMountain (M) is a mountain tile with a movement cost of 3.
|
||||||
|
KindMountain
|
||||||
|
// KindBlocker (X) is a tile which blocks movement.
|
||||||
|
KindBlocker
|
||||||
|
// KindFrom (F) is a tile which marks where the path should be calculated
|
||||||
|
// from.
|
||||||
|
KindFrom
|
||||||
|
// KindTo (T) is a tile which marks the goal of the path.
|
||||||
|
KindTo
|
||||||
|
// KindPath (●) is a tile to represent where the path is in the output.
|
||||||
|
KindPath
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindRunes map tile kinds to output runes.
|
||||||
|
var KindRunes = map[int]rune{
|
||||||
|
KindPlain: '.',
|
||||||
|
KindRiver: '~',
|
||||||
|
KindMountain: 'M',
|
||||||
|
KindBlocker: 'X',
|
||||||
|
KindFrom: 'F',
|
||||||
|
KindTo: 'T',
|
||||||
|
KindPath: '●',
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuneKinds map input runes to tile kinds.
|
||||||
|
var RuneKinds = map[rune]int{
|
||||||
|
'.': KindPlain,
|
||||||
|
'~': KindRiver,
|
||||||
|
'M': KindMountain,
|
||||||
|
'X': KindBlocker,
|
||||||
|
'F': KindFrom,
|
||||||
|
'T': KindTo,
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindCosts map tile kinds to movement costs.
|
||||||
|
var KindCosts = map[int]float64{
|
||||||
|
KindPlain: 1.0,
|
||||||
|
KindFrom: 1.0,
|
||||||
|
KindTo: 1.0,
|
||||||
|
KindRiver: 2.0,
|
||||||
|
KindMountain: 3.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Tile is a tile in a grid which implements Pather.
|
||||||
|
type Tile struct {
|
||||||
|
// Kind is the kind of tile, potentially affecting movement.
|
||||||
|
Kind int
|
||||||
|
// X and Y are the coordinates of the tile.
|
||||||
|
X, Y int
|
||||||
|
// W is a reference to the World that the tile is a part of.
|
||||||
|
W World
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathNeighbors returns the neighbors of the tile, excluding blockers and
|
||||||
|
// tiles off the edge of the board.
|
||||||
|
func (t *Tile) PathNeighbors() []Pather {
|
||||||
|
neighbors := []Pather{}
|
||||||
|
for _, offset := range [][]int{
|
||||||
|
{-1, 0},
|
||||||
|
{1, 0},
|
||||||
|
{0, -1},
|
||||||
|
{0, 1},
|
||||||
|
} {
|
||||||
|
if n := t.W.Tile(t.X+offset[0], t.Y+offset[1]); n != nil &&
|
||||||
|
n.Kind != KindBlocker {
|
||||||
|
neighbors = append(neighbors, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathNeighborCost returns the movement cost of the directly neighboring tile.
|
||||||
|
func (t *Tile) PathNeighborCost(to Pather) float64 {
|
||||||
|
toT := to.(*Tile)
|
||||||
|
return KindCosts[toT.Kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathEstimatedCost uses Manhattan distance to estimate orthogonal distance
|
||||||
|
// between non-adjacent nodes.
|
||||||
|
func (t *Tile) PathEstimatedCost(to Pather) float64 {
|
||||||
|
toT := to.(*Tile)
|
||||||
|
absX := toT.X - t.X
|
||||||
|
if absX < 0 {
|
||||||
|
absX = -absX
|
||||||
|
}
|
||||||
|
absY := toT.Y - t.Y
|
||||||
|
if absY < 0 {
|
||||||
|
absY = -absY
|
||||||
|
}
|
||||||
|
return float64(absX + absY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// World is a two dimensional map of Tiles.
|
||||||
|
type World map[int]map[int]*Tile
|
||||||
|
|
||||||
|
// Tile gets the tile at the given coordinates in the world.
|
||||||
|
func (w World) Tile(x, y int) *Tile {
|
||||||
|
if w[x] == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w[x][y]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTile sets a tile at the given coordinates in the world.
|
||||||
|
func (w World) SetTile(t *Tile, x, y int) {
|
||||||
|
if w[x] == nil {
|
||||||
|
w[x] = map[int]*Tile{}
|
||||||
|
}
|
||||||
|
w[x][y] = t
|
||||||
|
t.X = x
|
||||||
|
t.Y = y
|
||||||
|
t.W = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstOfKind gets the first tile on the board of a kind, used to get the from
|
||||||
|
// and to tiles as there should only be one of each.
|
||||||
|
func (w World) FirstOfKind(kind int) *Tile {
|
||||||
|
for _, row := range w {
|
||||||
|
for _, t := range row {
|
||||||
|
if t.Kind == kind {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// From gets the from tile from the world.
|
||||||
|
func (w World) From() *Tile {
|
||||||
|
return w.FirstOfKind(KindFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// To gets the to tile from the world.
|
||||||
|
func (w World) To() *Tile {
|
||||||
|
return w.FirstOfKind(KindTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPath renders a path on top of a world.
|
||||||
|
func (w World) RenderPath(path []Pather) string {
|
||||||
|
width := len(w)
|
||||||
|
if width == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
height := len(w[0])
|
||||||
|
pathLocs := map[string]bool{}
|
||||||
|
for _, p := range path {
|
||||||
|
pT := p.(*Tile)
|
||||||
|
pathLocs[fmt.Sprintf("%d,%d", pT.X, pT.Y)] = true
|
||||||
|
}
|
||||||
|
rows := make([]string, height)
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
t := w.Tile(x, y)
|
||||||
|
r := ' '
|
||||||
|
if pathLocs[fmt.Sprintf("%d,%d", x, y)] {
|
||||||
|
r = KindRunes[KindPath]
|
||||||
|
} else if t != nil {
|
||||||
|
r = KindRunes[t.Kind]
|
||||||
|
}
|
||||||
|
rows[y] += string(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(rows, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWorld parses a textual representation of a world into a world map.
|
||||||
|
func ParseWorld(input string) World {
|
||||||
|
w := World{}
|
||||||
|
for y, row := range strings.Split(strings.TrimSpace(input), "\n") {
|
||||||
|
for x, raw := range row {
|
||||||
|
kind, ok := RuneKinds[raw]
|
||||||
|
if !ok {
|
||||||
|
kind = KindBlocker
|
||||||
|
}
|
||||||
|
w.SetTile(&Tile{
|
||||||
|
Kind: kind,
|
||||||
|
}, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
35
d2common/d2astar/priority_queue.go
Normal file
35
d2common/d2astar/priority_queue.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package d2astar
|
||||||
|
|
||||||
|
// A priorityQueue implements heap.Interface and holds Nodes. The
|
||||||
|
// priorityQueue is used to track open nodes by rank.
|
||||||
|
type priorityQueue []*node
|
||||||
|
|
||||||
|
func (pq priorityQueue) Len() int {
|
||||||
|
return len(pq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pq priorityQueue) Less(i, j int) bool {
|
||||||
|
return pq[i].rank < pq[j].rank
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pq priorityQueue) Swap(i, j int) {
|
||||||
|
pq[i], pq[j] = pq[j], pq[i]
|
||||||
|
pq[i].index = i
|
||||||
|
pq[j].index = j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pq *priorityQueue) Push(x interface{}) {
|
||||||
|
n := len(*pq)
|
||||||
|
no := x.(*node)
|
||||||
|
no.index = n
|
||||||
|
*pq = append(*pq, no)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pq *priorityQueue) Pop() interface{} {
|
||||||
|
old := *pq
|
||||||
|
n := len(old)
|
||||||
|
no := old[n-1]
|
||||||
|
no.index = -1
|
||||||
|
*pq = old[0 : n-1]
|
||||||
|
return no
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
package d2common
|
package d2common
|
||||||
|
|
||||||
import "github.com/beefsack/go-astar"
|
import "github.com/OpenDiablo2/OpenDiablo2/d2common/d2astar"
|
||||||
|
|
||||||
type PathTile struct {
|
type PathTile struct {
|
||||||
Walkable bool
|
Walkable bool
|
||||||
@ -8,8 +8,8 @@ type PathTile struct {
|
|||||||
X, Y float64
|
X, Y float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PathTile) PathNeighbors() []astar.Pather {
|
func (t *PathTile) PathNeighbors() []d2astar.Pather {
|
||||||
result := make([]astar.Pather, 0)
|
result := make([]d2astar.Pather, 0, 8)
|
||||||
if t.Up != nil {
|
if t.Up != nil {
|
||||||
result = append(result, t.Up)
|
result = append(result, t.Up)
|
||||||
}
|
}
|
||||||
@ -38,11 +38,11 @@ func (t *PathTile) PathNeighbors() []astar.Pather {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PathTile) PathNeighborCost(to astar.Pather) float64 {
|
func (t *PathTile) PathNeighborCost(to d2astar.Pather) float64 {
|
||||||
return 1 // No cost specifics currently...
|
return 1 // No cost specifics currently...
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PathTile) PathEstimatedCost(to astar.Pather) float64 {
|
func (t *PathTile) PathEstimatedCost(to d2astar.Pather) float64 {
|
||||||
toT := to.(*PathTile)
|
toT := to.(*PathTile)
|
||||||
absX := toT.X - t.X
|
absX := toT.X - t.X
|
||||||
if absX < 0 {
|
if absX < 0 {
|
||||||
|
@ -4,8 +4,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/OpenDiablo2/OpenDiablo2/d2common"
|
"github.com/OpenDiablo2/OpenDiablo2/d2common"
|
||||||
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2astar"
|
||||||
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
|
||||||
"github.com/beefsack/go-astar"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *MapEngine) RegenerateWalkPaths() {
|
func (m *MapEngine) RegenerateWalkPaths() {
|
||||||
@ -69,7 +69,7 @@ func (m *MapEngine) RegenerateWalkPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finds a walkable path between two points
|
// Finds a walkable path between two points
|
||||||
func (m *MapEngine) PathFind(startX, startY, endX, endY float64) (path []astar.Pather, distance float64, found bool) {
|
func (m *MapEngine) PathFind(startX, startY, endX, endY float64) (path []d2astar.Pather, distance float64, found bool) {
|
||||||
startTileX := int(math.Floor(startX))
|
startTileX := int(math.Floor(startX))
|
||||||
startTileY := int(math.Floor(startY))
|
startTileY := int(math.Floor(startY))
|
||||||
if !m.TileExists(startTileX, startTileY) {
|
if !m.TileExists(startTileX, startTileY) {
|
||||||
@ -97,7 +97,7 @@ func (m *MapEngine) PathFind(startX, startY, endX, endY float64) (path []astar.P
|
|||||||
}
|
}
|
||||||
endNode := &m.walkMesh[endNodeIndex]
|
endNode := &m.walkMesh[endNodeIndex]
|
||||||
|
|
||||||
path, distance, found = astar.Path(endNode, startNode)
|
path, distance, found = d2astar.Path(endNode, startNode, 80)
|
||||||
if path != nil {
|
if path != nil {
|
||||||
path = path[1:]
|
path = path[1:]
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/OpenDiablo2/OpenDiablo2/d2common"
|
"github.com/OpenDiablo2/OpenDiablo2/d2common"
|
||||||
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2astar"
|
||||||
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2render"
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2render"
|
||||||
"github.com/beefsack/go-astar"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MapEntity interface {
|
type MapEntity interface {
|
||||||
@ -25,7 +25,7 @@ type mapEntity struct {
|
|||||||
TargetX float64
|
TargetX float64
|
||||||
TargetY float64
|
TargetY float64
|
||||||
Speed float64
|
Speed float64
|
||||||
path []astar.Pather
|
path []d2astar.Pather
|
||||||
|
|
||||||
done func()
|
done func()
|
||||||
directioner func(angle float64)
|
directioner func(angle float64)
|
||||||
@ -44,11 +44,11 @@ func createMapEntity(x, y int) mapEntity {
|
|||||||
subcellX: 1 + math.Mod(locX, 5),
|
subcellX: 1 + math.Mod(locX, 5),
|
||||||
subcellY: 1 + math.Mod(locY, 5),
|
subcellY: 1 + math.Mod(locY, 5),
|
||||||
Speed: 6,
|
Speed: 6,
|
||||||
path: []astar.Pather{},
|
path: []d2astar.Pather{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mapEntity) SetPath(path []astar.Pather, done func()) {
|
func (m *mapEntity) SetPath(path []d2astar.Pather, done func()) {
|
||||||
m.path = path
|
m.path = path
|
||||||
m.done = done
|
m.done = done
|
||||||
}
|
}
|
||||||
@ -112,7 +112,7 @@ func (m *mapEntity) Step(tickTime float64) {
|
|||||||
if len(m.path) > 1 {
|
if len(m.path) > 1 {
|
||||||
m.path = m.path[1:]
|
m.path = m.path[1:]
|
||||||
} else {
|
} else {
|
||||||
m.path = []astar.Pather{}
|
m.path = []d2astar.Pather{}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.LocationX = m.TargetX
|
m.LocationX = m.TargetX
|
||||||
|
1
go.mod
1
go.mod
@ -6,7 +6,6 @@ require (
|
|||||||
github.com/JoshVarga/blast v0.0.0-20180421040937-681c804fb9f0
|
github.com/JoshVarga/blast v0.0.0-20180421040937-681c804fb9f0
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
|
||||||
github.com/beefsack/go-astar v0.0.0-20171024231011-f324bbb0d6f7
|
|
||||||
github.com/go-restruct/restruct v0.0.0-20191227155143-5734170a48a1
|
github.com/go-restruct/restruct v0.0.0-20191227155143-5734170a48a1
|
||||||
github.com/hajimehoshi/ebiten v1.11.2
|
github.com/hajimehoshi/ebiten v1.11.2
|
||||||
github.com/pkg/profile v1.5.0
|
github.com/pkg/profile v1.5.0
|
||||||
|
2
go.sum
2
go.sum
@ -5,8 +5,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafo
|
|||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
github.com/beefsack/go-astar v0.0.0-20171024231011-f324bbb0d6f7 h1:dX/NcR4V4sY+xio5sjMUUaBfmXz/7UH4R7S//oVPqhY=
|
|
||||||
github.com/beefsack/go-astar v0.0.0-20171024231011-f324bbb0d6f7/go.mod h1:Cu3t5VeqE8kXjUBeNXWQprfuaP5UCIc5ggGjgMx9KFc=
|
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
|
||||||
|
Loading…
Reference in New Issue
Block a user