diff --git a/features/eid_api.lua b/features/eid_api.lua index 35939661..07469f14 100644 --- a/features/eid_api.lua +++ b/features/eid_api.lua @@ -37,6 +37,8 @@ setmetatable(__eidEntityDescriptions, end }) --------------------------------------------------------------------------- -------------------------Handle API Functions ----------------------------- +EID.Pathfinder = require("features.pathfinder.luafinding") + local nullVector = Vector(0,0) local game = Game() local maxCardID = Card.NUM_CARDS - 1 @@ -3116,3 +3118,36 @@ function EID:IsItemHidden(entity) end return false end + +---Returns true if the position at the given grid location is valid for pathfinding. +---gridPosition is a table with an x and y entry. Its derived from the FindVector class of "LuaFindings" module (pathfinder) +---@param gridPosition table +---@return boolean +function EID:EvaluateLocation(gridPosition) + local room = game:GetRoom() + local width = room:GetGridWidth() + local height = room:GetGridHeight() + if gridPosition.x < 1 or gridPosition.y < 1 or gridPosition.x >= height-1 or gridPosition.y >= width - 1 then + return false + end + local gridIndex = gridPosition.x * room:GetGridWidth() + gridPosition.y + local collision = room:GetGridCollision(gridIndex) + return collision == GridCollisionClass.COLLISION_NONE or collision == GridCollisionClass.COLLISION_WALL_EXCEPT_PLAYER +end + +---Returns true if an unobstructed path between the start and end position exists. +---Obstructions can be something like Rocks, Pits, Walls, etc. +---@param startPos Vector +---@param endPos Vector +---@return boolean +function EID:HasPathToPosition(startPos, endPos) + -- divide by 40 to convert from world to grid coords + local room = game:GetRoom() + local width = room:GetGridWidth() + local startGI = room:GetGridIndex(startPos) + local endGI = room:GetGridIndex(endPos) + -- convert GridIndex into Grid X,Y coordinates and calculate path + local pathfinderObj = EID.Pathfinder(Vector(startGI%width, math.floor(startGI/width)), Vector(endGI%width, math.floor(endGI/width)), EID.EvaluateLocation) + -- return true if it has a path + return pathfinderObj:GetPath() ~= nil +end diff --git a/features/pathfinder/CREDITS.md b/features/pathfinder/CREDITS.md new file mode 100644 index 00000000..0cc104f1 --- /dev/null +++ b/features/pathfinder/CREDITS.md @@ -0,0 +1,4 @@ +This A*-pathfinder module is based on the following git repo: +https://github.com/GlorifiedPig/Luafinding + +Licence: MIT \ No newline at end of file diff --git a/features/pathfinder/find_vector.lua b/features/pathfinder/find_vector.lua new file mode 100644 index 00000000..eb389efd --- /dev/null +++ b/features/pathfinder/find_vector.lua @@ -0,0 +1,52 @@ + +local FindVector = {} +FindVector.__index = FindVector + +local function newFindVector( x, y ) + return setmetatable( { x = x or 0, y = y or 0 }, FindVector ) +end + +function FindVector.__unm( vTbl ) + return newFindVector( -vTbl.x, -vTbl.y ) +end + +function FindVector.__add( a, b ) + return newFindVector( a.x + b.x, a.y + b.y ) +end + +function FindVector.__sub( a, b ) + return newFindVector( a.x - b.x, a.y - b.y ) +end + +function FindVector.__mul( a, b ) + if type( a ) == "number" then + return newFindVector( a * b.x, a * b.y ) + elseif type( b ) == "number" then + return newFindVector( a.x * b, a.y * b ) + else + return newFindVector( a.x * b.x, a.y * b.y ) + end +end + +function FindVector.__div( a, b ) + return newFindVector( a.x / b, a.y / b ) +end + +function FindVector.__eq( a, b ) + return a.x == b.x and a.y == b.y +end + +function FindVector:__tostring() + return "(" .. self.x .. ", " .. self.y .. ")" +end + +function FindVector:ID() + if self._ID == nil then + local x, y = self.x, self.y + self._ID = 0.5 * ( ( x + y ) * ( x + y + 1 ) + y ) + end + + return self._ID +end + +return setmetatable( FindVector, { __call = function( _, ... ) return newFindVector( ... ) end } ) \ No newline at end of file diff --git a/features/pathfinder/heap.lua b/features/pathfinder/heap.lua new file mode 100644 index 00000000..4c6cfd46 --- /dev/null +++ b/features/pathfinder/heap.lua @@ -0,0 +1,77 @@ + +local Heap = {} +Heap.__index = Heap + +local function findLowest( a, b ) + return a < b +end + +local function newHeap( template, compare ) + return setmetatable( { + Data = {}, + Compare = compare or findLowest, + Size = 0 + }, template ) +end + +local function sortUp( heap, index ) + if index <= 1 then return end + local pIndex = index % 2 == 0 and index / 2 or ( index - 1 ) / 2 + + if not heap.Compare( heap.Data[pIndex], heap.Data[index] ) then + heap.Data[pIndex], heap.Data[index] = heap.Data[index], heap.Data[pIndex] + sortUp( heap, pIndex ) + end +end + +local function sortDown( heap, index ) + local leftIndex, rightIndex, minIndex + leftIndex = index * 2 + rightIndex = leftIndex + 1 + if rightIndex > heap.Size then + if leftIndex > heap.Size then return + else minIndex = leftIndex end + else + if heap.Compare( heap.Data[leftIndex], heap.Data[rightIndex] ) then minIndex = leftIndex + else minIndex = rightIndex end + end + + if not heap.Compare( heap.Data[index], heap.Data[minIndex] ) then + heap.Data[index], heap.Data[minIndex] = heap.Data[minIndex], heap.Data[index] + sortDown( heap, minIndex ) + end +end + +function Heap:Empty() + return self.Size == 0 +end + +function Heap:Clear() + self.Data, self.Size, self.Compare = {}, 0, self.Compare or findLowest + return self +end + +function Heap:Push( item ) + if item then + self.Size = self.Size + 1 + self.Data[self.Size] = item + sortUp( self, self.Size ) + end + return self +end + +function Heap:Pop() + local root + if self.Size > 0 then + root = self.Data[1] + self.Data[1] = self.Data[self.Size] + self.Data[self.Size] = nil + self.Size = self.Size - 1 + if self.Size > 1 then + sortDown( self, 1 ) + end + end + return root +end + +return setmetatable( Heap, { __call = function( self, ... ) return newHeap( self, ... ) end } ) \ No newline at end of file diff --git a/features/pathfinder/luafinding.lua b/features/pathfinder/luafinding.lua new file mode 100644 index 00000000..7be429e5 --- /dev/null +++ b/features/pathfinder/luafinding.lua @@ -0,0 +1,146 @@ + +-- Positions must be a table (or metatable) where table.x and table.y are accessible. + +local FindVector = require( "features.pathfinder.find_vector" ) +local Heap = require( "features.pathfinder.heap" ) + +local Luafinding = {} +Luafinding.__index = Luafinding + +-- This instantiates a new Luafinding class for usage later. +-- "start" and "finish" should both be 2 dimensional FindVectors, or just a table with "x" and "y" keys. See the note at the top of this file. +-- positionOpenCheck can be a function or a table. +-- If it's a function it must have a return value of true or false depending on whether or not the position is open. +-- If it's a table it should simply be a table of values such as "pos[x][y] = true". +function Luafinding:Initialize( start, finish, positionOpenCheck ) + local newPath = setmetatable( { Start = FindVector(start.Y,start.X), Finish = FindVector(finish.Y,finish.X), PositionOpenCheck = positionOpenCheck }, Luafinding ) + newPath:CalculatePath() + return newPath +end + +local function distance( start, finish ) + local dx = start.x - finish.x + local dy = start.y - finish.y + return dx * dx + dy * dy +end + +local positionIsOpen +local function positionIsOpenTable( pos, check ) return check[pos.x] and check[pos.x][pos.y] end +local function positionIsOpenCustom( pos, check ) return check( _, pos ) end + +local adjacentPositions = { + FindVector( 0, -1 ), + FindVector( -1, 0 ), + FindVector( 0, 1 ), + FindVector( 1, 0 ), +} + +local function fetchOpenAdjacentNodes( pos, positionOpenCheck ) + local result = {} + + for i = 1, #adjacentPositions do + local adjacentPos = pos + adjacentPositions[i] + if positionIsOpen( adjacentPos, positionOpenCheck ) then + table.insert( result, adjacentPos ) + end + end + + return result +end + +-- This is the function used to actually calculate the path. +-- It returns the calcated path table, or nil if it cannot find a path. +function Luafinding:CalculatePath() + local start, finish, positionOpenCheck = self.Start, self.Finish, self.PositionOpenCheck + if not positionOpenCheck then return end + positionIsOpen = type( positionOpenCheck ) == "table" and positionIsOpenTable or positionIsOpenCustom + if not positionIsOpen( finish, positionOpenCheck ) then return end + -- EID edit: check if end position is surrounded by stuff and therefore unaccessable. This is done for a quick early out + local directlyAdjacents = fetchOpenAdjacentNodes( self.Finish, positionOpenCheck ) + if #directlyAdjacents == 0 then return end + -- edit end + local open, closed = Heap(), {} + + start.gScore = 0 + start.hScore = distance( start, finish ) + start.fScore = start.hScore + + open.Compare = function( a, b ) + return a.fScore < b.fScore + end + + open:Push( start ) + + while not open:Empty() do + local current = open:Pop() + local currentId = current:ID() + if not closed[currentId] then + if current == finish then + local path = {} + while true do + if current.previous then + table.insert( path, 1, current ) + current = current.previous + else + table.insert( path, 1, start ) + self.Path = path + return path + end + end + end + + closed[currentId] = true + + local adjacents = fetchOpenAdjacentNodes( current, positionOpenCheck ) + for i = 1, #adjacents do + local adjacent = adjacents[i] + if not closed[adjacent:ID()] then + local added_gScore = current.gScore + distance( current, adjacent ) + + if not adjacent.gScore or added_gScore < adjacent.gScore then + adjacent.gScore = added_gScore + if not adjacent.hScore then + adjacent.hScore = distance( adjacent, finish ) + end + adjacent.fScore = added_gScore + adjacent.hScore + + open:Push( adjacent ) + adjacent.previous = current + end + end + end + end + end +end + +function Luafinding:GetPath() + return self.Path +end + +function Luafinding:GetDistance() + local path = self.Path + if not path then return end + return distance( path[1], path[#path] ) +end + +function Luafinding:GetTiles() + local path = self.Path + if not path then return end + return #path +end + +function Luafinding:__tostring() + local path = self.Path + local string = "" + + if path then + for k, v in ipairs( path ) do + local formatted = ( k .. ": " .. v ) + string = k == 1 and formatted or string .. "\n" .. formatted + end + end + + return string +end + +return setmetatable( Luafinding, { __call = function( self, ... ) return self:Initialize( ... ) end } ) \ No newline at end of file diff --git a/main.lua b/main.lua index 8e6cba12..de1ffb00 100644 --- a/main.lua +++ b/main.lua @@ -1138,7 +1138,6 @@ end EID:AddCallback(ModCallbacks.MC_POST_PICKUP_INIT, EID.CollectibleSpawnedThisFrame, PickupVariant.PICKUP_COLLECTIBLE) -- Pathchecking -local pathCheckerEntity = nil local lastPathfindFrame = -1 local function attemptPathfind(entity) @@ -1146,18 +1145,13 @@ local function attemptPathfind(entity) pathsChecked[entity.InitSeed] = true return true end - -- Don't reattempt pathfinding more than 3 times a second, unless this is a new entity - if pathsChecked[entity.InitSeed] == false and EID.GameUpdateCount - lastPathfindFrame < 10 then return false end + -- Don't reattempt pathfinding more than 2 times a second, unless this is a new entity + if pathsChecked[entity.InitSeed] == false and EID.GameUpdateCount - lastPathfindFrame < 15 then return false end - -- Spawn a Fireplace entity to attempt a pathfind to the target pickup, then remove it afterwards - pathCheckerEntity = game:Spawn(33, 0, EID.player.Position, nullVector, EID.player, 6969, 4354) - EID:setEntityData(pathCheckerEntity, "EID_Pathfinder", true) - pathCheckerEntity.Visible = false - local successful = pathCheckerEntity:ToNPC().Pathfinder:HasPathToPos(entity.Position, false) + local successful = EID:HasPathToPosition(EID.player.Position, entity.Position) pathsChecked[entity.InitSeed] = successful - pathCheckerEntity:Remove() - pathCheckerEntity = nil lastPathfindFrame = EID.GameUpdateCount + return successful end