]> git.lizzy.rs Git - lua-star.git/commitdiff
Add lua star, example and documentation.
authorWesley <wesley.werner@gmail.com>
Sat, 2 Dec 2017 11:47:43 +0000 (13:47 +0200)
committerWesley <wesley.werner@gmail.com>
Sat, 2 Dec 2017 11:47:43 +0000 (13:47 +0200)
.busted [new file with mode: 0644]
.gitignore [new file with mode: 0644]
README.md
example/lua-star-01.png [new file with mode: 0644]
example/main.lua [new file with mode: 0644]
src/lua-star.lua [new file with mode: 0644]
tests/lua-star_spec.lua [new file with mode: 0644]

diff --git a/.busted b/.busted
new file mode 100644 (file)
index 0000000..9a81402
--- /dev/null
+++ b/.busted
@@ -0,0 +1,7 @@
+return {
+  default = {
+    verbose = true,
+    coverage = false,
+    ROOT = {"tests"},
+  }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..a813bbb
--- /dev/null
@@ -0,0 +1 @@
+example/lua-star.lua
index 0d4142a2564b3709c194984fd7194903f288de5b..4f22b84ccd35fc5fcae6050517cc6e6e14fc37dd 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1,2 +1,69 @@
 # lua-star
+
 Easy A* path finding for Lua
+
+[lua star example screenshot](example/lua-star-01.png)
+
+# Quick Start
+
+Easy to use, it will make you more attractive and you feel sensual doing so.
+
+    local luastar = require("lua-star")
+
+    function positionIsOpenFunc(x, y)
+        -- should return true if the position is open to walk
+        return mymap[x][y] == walkable
+    end
+
+    local path = luastar:find(width, height, start, goal, positionIsOpenFunc, useCache)
+
+`path` will be false if no path was found, otherwise it contains a list of points that travel from `start` to `goal`:
+
+    if path then
+        for _, p in ipairs(path) do
+            print(p.x, p.y)
+        end
+    end
+
+Lua star does not care how your map data is arranged, it simply asks you if the map position at `x,y` is walkable via a callback.
+
+`width` and `height` is your map size.
+
+`start` and `goal` are tables with at least the `x` and `y` keys.
+
+    local start = { x = 1, y = 10 }
+    local goal = { x = 10, y = 1 }
+
+`positionIsOpenFunc(x, y)` is a function that should return true if the position is open to walk.
+
+`useCache` is optional and defaults to `false` when not given. If you have a map that does not change, caching can give a speed boost.
+
+If at any time you need to clear all cached paths;
+
+    luastar:clearCached()
+
+# Requirements
+
+* [Lua 5.x](http://www.lua.org/)
+
+For running unit tests:
+
+* [Lua Rocks](https://luarocks.org/)
+* busted
+
+These commands are for apt-based systems, please adapt to them as needed.
+
+    sudo apt-get install luarocks
+    sudo luarocks install busted
+
+Unit testing is done with busted, the `.busted` config already defines everything, so simply run:
+
+    busted
+
+# Example
+
+There is an [interactive example](example/main.lua) that can be run with [Love](https://love2d.org).
+
+# License
+
+See the file [LICENSE](LICENSE)
diff --git a/example/lua-star-01.png b/example/lua-star-01.png
new file mode 100644 (file)
index 0000000..50bbed8
Binary files /dev/null and b/example/lua-star-01.png differ
diff --git a/example/main.lua b/example/main.lua
new file mode 100644 (file)
index 0000000..460d99c
--- /dev/null
@@ -0,0 +1,107 @@
+--[[
+
+   Lua star example - Run with love (https://love2d.org/)
+
+   Copyright 2017 wesley werner <wesley.werner@gmail.com>
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program. If not, see http://www.gnu.org/licenses/.
+
+]]--
+
+local luastar = require("lua-star")
+
+-- a 2D map where true is open and false is blocked
+local map = { }
+local mapsize = 10
+local screensize = 500
+local tilesize = screensize / mapsize
+
+-- path start and end
+local path = nil
+local start = { x = 1, y = 10 }
+local goal = { x = 10, y = 1 }
+
+function love.load()
+
+    love.window.setMode( screensize, screensize )
+
+    -- build an open map
+    for x=1, mapsize do
+        map[x] = {}
+        for y=1, mapsize do
+            map[x][y] = true
+        end
+    end
+
+    requestPath()
+
+end
+
+function love.keypressed(key)
+
+    if key == "escape" then
+        love.event.quit()
+    end
+
+end
+
+function love.draw()
+
+    -- draw walls
+    love.graphics.setColor(255, 255, 255)
+    for x=1, mapsize do
+        for y=1, mapsize do
+            local fillstyle = "line"
+            if map[x][y] == false then fillstyle = "fill" end
+            love.graphics.rectangle(fillstyle, (x-1)*tilesize, (y-1)*tilesize, tilesize, tilesize)
+        end
+    end
+
+    -- draw start and end
+    love.graphics.print("START", (start.x-1) * tilesize, (start.y-1) * tilesize)
+    love.graphics.print("GOAL", (goal.x-1) * tilesize, (goal.y-1) * tilesize)
+
+    -- draw the path
+    if path then
+        for i, p in ipairs(path) do
+            love.graphics.setColor(0, 128, 0)
+            love.graphics.rectangle("fill", (p.x-1)*tilesize, (p.y-1)*tilesize, tilesize, tilesize)
+            love.graphics.setColor(255, 255, 255)
+            love.graphics.print(i, (p.x-1) * tilesize, (p.y-1) * tilesize)
+        end
+    end
+
+end
+
+function love.mousepressed( x, y, button, istouch )
+
+    local dx = math.floor(x / tilesize) + 1
+    local dy = math.floor(y / tilesize) + 1
+    map[dx][dy] = not map[dx][dy]
+    requestPath()
+
+end
+
+function positionIsOpenFunc(x, y)
+
+    -- should return true if the position is open to walk
+    return map[x][y]
+
+end
+
+function requestPath()
+
+    path = luastar:find(mapsize, mapsize, start, goal, positionIsOpenFunc)
+
+end
diff --git a/src/lua-star.lua b/src/lua-star.lua
new file mode 100644 (file)
index 0000000..38c512c
--- /dev/null
@@ -0,0 +1,208 @@
+--[[
+   lua-star.lua
+
+   Copyright 2017 wesley werner <wesley.werner@gmail.com>
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program. If not, see http://www.gnu.org/licenses/.
+
+   References:
+   https://en.wikipedia.org/wiki/A*_search_algorithm
+   https://www.redblobgames.com/pathfinding/a-star/introduction.html
+   https://www.raywenderlich.com/4946/introduction-to-a-pathfinding
+]]--
+
+--- Provides easy A* path finding.
+-- @module lua-star
+
+local module = {}
+
+--- Clears all cached paths.
+function module:clearCached()
+    module.cache = nil
+end
+
+-- (Internal) Returns a unique key for the start and end points.
+local function keyOf(start, goal)
+    return string.format("%d,%d>%d,%d", start.x, start.y, goal.x, goal.y)
+end
+
+-- (Internal) Returns the cached path for start and end points.
+local function getCached(start, goal)
+    if module.cache then
+        local key = keyOf(start, goal)
+        return module.cache[key]
+    end
+end
+
+-- (Internal) Saves a path to the cache.
+local function saveCached(start, goal, path)
+    module.cache = module.cache or { }
+    local key = keyOf(start, goal)
+    module.cache[key] = path
+end
+
+-- (Internal) Return the distance between two points.
+-- This method doesn't bother getting the square root of s, it is faster
+-- and it still works for our use.
+local function distance(x1, y1, x2, y2)
+  local dx = x1 - x2
+  local dy = y1 - y2
+  local s = dx * dx + dy * dy
+  return s
+end
+
+-- (Internal) Clamp a value to a range.
+local function clamp(x, min, max)
+  return x < min and min or (x > max and max or x)
+end
+
+-- (Internal) Return the score of a node.
+-- G is the cost from START to this node.
+-- H is a heuristic cost, in this case the distance from this node to the goal.
+-- Returns F, the sum of G and H.
+local function calculateScore(previous, node, goal)
+
+    local G = previous.score + 1
+    local H = distance(node.x, node.y, goal.x, goal.y)
+    return G + H, G, H
+
+end
+
+-- (Internal) Returns true if the given list contains the specified item.
+local function listContains(list, item)
+    for _, test in ipairs(list) do
+        if test.x == item.x and test.y == item.y then
+            return true
+        end
+    end
+    return false
+end
+
+-- (Internal) Returns the item in the given list.
+local function listItem(list, item)
+    for _, test in ipairs(list) do
+        if test.x == item.x and test.y == item.y then
+            return test
+        end
+    end
+end
+
+-- (Internal) Requests adjacent map values around the given node.
+local function getAdjacent(width, height, node, positionIsOpenFunc)
+
+    local result = { }
+
+    local positions = {
+        { x = 0, y = -1 },  -- top
+        { x = -1, y = 0 },  -- left
+        { x = 0, y = 1 },   -- bottom
+        { x = 1, y = 0 },   -- right
+        -- include diagonal movements
+        { x = -1, y = -1 },   -- top left
+        { x = 1, y = -1 },   -- top right
+        { x = -1, y = 1 },   -- bot left
+        { x = 1, y = 1 },   -- bot right
+    }
+
+    for _, point in ipairs(positions) do
+        local px = clamp(node.x + point.x, 1, width)
+        local py = clamp(node.y + point.y, 1, height)
+        local value = positionIsOpenFunc( px, py )
+        if value then
+            table.insert( result, { x = px, y = py  } )
+        end
+    end
+
+    return result
+
+end
+
+-- Returns the path from start to goal, or false if no path exists.
+function module:find(width, height, start, goal, positionIsOpenFunc, useCache)
+
+    if useCache then
+        local cachedPath = getCached(start, goal)
+        if cachedPath then
+            return cachedPath
+        end
+    end
+
+    local success = false
+    local open = { }
+    local closed = { }
+
+    start.score = 0
+    start.G = 0
+    start.H = distance(start.x, start.y, goal.x, goal.y)
+    start.parent = { x = 0, y = 0 }
+    table.insert(open, start)
+
+    while not success and #open > 0 do
+
+        -- sort by score: high to low
+        table.sort(open, function(a, b) return a.score > b.score end)
+
+        local current = table.remove(open)
+
+        table.insert(closed, current)
+
+        success = listContains(closed, goal)
+
+        if not success then
+
+            local adjacentList = getAdjacent(width, height, current, positionIsOpenFunc)
+
+            for _, adjacent in ipairs(adjacentList) do
+
+                if not listContains(closed, adjacent) then
+
+                    if not listContains(open, adjacent) then
+
+                        adjacent.score = calculateScore(current, adjacent, goal)
+                        adjacent.parent = current
+                        table.insert(open, adjacent)
+
+                    end
+
+                end
+
+            end
+
+        end
+
+    end
+
+    if not success then
+        return false
+    end
+
+    -- traverse the parents from the last point to get the path
+    local node = listItem(closed, closed[#closed])
+    local path = { }
+
+    while node do
+
+        table.insert(path, 1, { x = node.x, y = node.y } )
+        node = listItem(closed, node.parent)
+
+    end
+
+    saveCached(start, goal, path)
+
+    -- reverse the closed list to get the solution
+    return path
+
+end
+
+return module
diff --git a/tests/lua-star_spec.lua b/tests/lua-star_spec.lua
new file mode 100644 (file)
index 0000000..65a8722
--- /dev/null
@@ -0,0 +1,250 @@
+describe("Lua star", function()
+
+    -- start is always top left (1,1)
+    -- goal is always bottom right (10, 10)
+    local start = { x = 1, y = 1 }
+    local goal = { x = 10, y = 10 }
+    local map = nil
+
+    -- define some test maps (10 x 10)
+    local mapsize = 10
+    local openmap = [[
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                ]]
+
+    local openmapSolution = {
+        { x = 1, y = 1 },
+        { x = 2, y = 2 },
+        { x = 3, y = 3 },
+        { x = 4, y = 4 },
+        { x = 5, y = 5 },
+        { x = 6, y = 6 },
+        { x = 7, y = 7 },
+        { x = 8, y = 8 },
+        { x = 9, y = 9 },
+        { x = 10, y = 10 },
+    }
+
+    local simplemap = [[
+                0000000000
+                0000000110
+                0000001110
+                0000011100
+                0000111000
+                0001110000
+                0011100000
+                0111000000
+                0000000000
+                0000000000
+                ]]
+
+    local simplemapSolution = {
+        { x = 1, y = 1 },
+        { x = 2, y = 2 },
+        { x = 3, y = 3 },
+        { x = 4, y = 4 },
+        { x = 4, y = 5 },
+        { x = 3, y = 6 },
+        { x = 2, y = 7 },
+        { x = 1, y = 8 },
+        { x = 2, y = 9 },
+        { x = 3, y = 10 },
+        { x = 4, y = 10 },
+        { x = 5, y = 10 },
+        { x = 6, y = 10 },
+        { x = 7, y = 10 },
+        { x = 8, y = 10 },
+        { x = 9, y = 10 },
+        { x = 10, y = 10 },
+    }
+
+    local complexmap = [[
+                0000000000
+                1111111110
+                0000000000
+                0111111111
+                0100110000
+                0101010100
+                0001010110
+                1111011010
+                0000000010
+                0000000010
+                ]]
+
+    local complexmapSolution = {
+        { x = 1, y = 1 },
+        { x = 2, y = 1 },
+        { x = 3, y = 1 },
+        { x = 4, y = 1 },
+        { x = 5, y = 1 },
+        { x = 6, y = 1 },
+        { x = 7, y = 1 },
+        { x = 8, y = 1 },
+        { x = 9, y = 1 },
+        { x = 10, y = 2 },
+        { x = 9, y = 3 },
+        { x = 8, y = 3 },
+        { x = 7, y = 3 },
+        { x = 6, y = 3 },
+        { x = 5, y = 3 },
+        { x = 4, y = 3 },
+        { x = 3, y = 3 },
+        { x = 2, y = 3 },
+        { x = 1, y = 4 },
+        { x = 1, y = 5 },
+        { x = 1, y = 6 },
+        { x = 2, y = 7 },
+        { x = 3, y = 6 },
+        { x = 4, y = 5 },
+        { x = 5, y = 6 },
+        { x = 5, y = 7 },
+        { x = 5, y = 8 },
+        { x = 6, y = 9 },
+        { x = 7, y = 9 },
+        { x = 8, y = 8 },
+        { x = 7, y = 7 },
+        { x = 7, y = 6 },
+        { x = 8, y = 5 },
+        { x = 9, y = 6 },
+        { x = 10, y = 7 },
+        { x = 10, y = 8 },
+        { x = 10, y = 9 },
+        { x = 10, y = 10 },
+    }
+
+    local unsolvablemap = [[
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                1111111111
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                0000000000
+                ]]
+
+    -- convert a string map into a table
+    local function makemap(template)
+        map = { }
+        template:gsub(".", function(c)
+            if c == "0" or c == "1" then
+                table.insert(map, c)
+            end
+        end)
+    end
+
+    -- get the value at position xy on a map
+    local function mapTileIsOpen(x, y)
+        return map[ ((y-1) * 10) + x ] == "0"
+    end
+
+    local function printSolution(path)
+        print(#path, "points")
+        for i, v in ipairs(path) do
+            print(string.format("{ x = %d, y = %d },", v.x, v.y))
+        end
+        for h=1, mapsize do
+            for w=1, mapsize do
+                local walked = false
+                for _, p in ipairs(path) do
+                    if p.x == w and p.y == h then
+                        walked = true
+                    end
+                end
+                if walked then
+                    io.write(".")
+                else
+                    io.write("#")
+                end
+            end
+            io.write("\n")
+        end
+    end
+
+    -- begin tests
+
+    it("find a path with no obstacles", function()
+
+        local luastar = require("lua-star")
+        makemap(openmap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        --printSolution(path)
+        assert.are.equal(10, #path)
+        assert.are.same(openmapSolution, path)
+
+    end)
+
+    it("find a path on a simple map", function()
+
+        local luastar = require("lua-star")
+        makemap(simplemap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        --printSolution(path)
+        assert.are.equal(17, #path)
+        assert.are.same(simplemapSolution, path)
+
+    end)
+
+    it("find a path on a complex map", function()
+
+        local luastar = require("lua-star")
+        makemap(complexmap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        --printSolution(path)
+        assert.are.equal(38, #path)
+        assert.are.same(complexmapSolution, path)
+
+    end)
+
+    it("find no path", function()
+
+        local luastar = require("lua-star")
+        makemap(unsolvablemap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        assert.is_false(path)
+
+    end)
+
+    it("does not cache paths by default", function()
+
+        local luastar = require("lua-star")
+        makemap(openmap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        local samepath = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen)
+        assert.is_not.equal(path, samepath)
+
+    end)
+
+    it("caches paths", function()
+
+        local luastar = require("lua-star")
+        makemap(openmap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen, true)
+        local samepath = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen, true)
+        assert.are.equal(path, samepath)
+
+    end)
+
+    it("clears cached paths", function()
+
+        local luastar = require("lua-star")
+        makemap(openmap)
+        local path = luastar:find(mapsize, mapsize, start, goal, mapTileIsOpen, true)
+        luastar:clearCached()
+        assert.is_nil(luastar.cache)
+
+    end)
+
+end)
+