feat(entity): implement path_to() method for entity pathfinding

- Add path_to(target_x, target_y) method to UIEntity class
- Uses existing Dijkstra pathfinding implementation from UIGrid
- Returns list of (x, y) coordinate tuples for complete path
- Supports both positional and keyword argument formats
- Proper error handling for out-of-bounds and no-grid scenarios
- Comprehensive test suite covering normal and edge cases

Part of TCOD integration sprint - gives entities immediate pathfinding capabilities.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-07-09 13:01:03 -04:00
commit 7ee0a08662
11 changed files with 1163 additions and 30 deletions

View file

@ -7,7 +7,7 @@
UIGrid::UIGrid()
: grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255) // Default dark gray background
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr) // Default dark gray background
{
// Initialize entities list
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
@ -27,13 +27,14 @@ UIGrid::UIGrid()
output.setTexture(renderTexture.getTexture());
// Points vector starts empty (grid_x * grid_y = 0)
// TCOD map will be created when grid is resized
}
UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _xy, sf::Vector2f _wh)
: grid_x(gx), grid_y(gy),
zoom(1.0f),
ptex(_ptex), points(gx * gy),
fill_color(8, 8, 8, 255) // Default dark gray background
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr) // Default dark gray background
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
@ -63,6 +64,24 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
// textures are upside-down inside renderTexture
output.setTexture(renderTexture.getTexture());
// Create TCOD map
tcod_map = new TCODMap(gx, gy);
// Create TCOD dijkstra pathfinder
tcod_dijkstra = new TCODDijkstra(tcod_map);
// Initialize grid points with parent reference
for (int y = 0; y < gy; y++) {
for (int x = 0; x < gx; x++) {
int idx = y * gx + x;
points[idx].grid_x = x;
points[idx].grid_y = y;
points[idx].parent_grid = this;
}
}
// Initial sync of TCOD map
syncTCODMap();
}
void UIGrid::update() {}
@ -234,11 +253,116 @@ UIGridPoint& UIGrid::at(int x, int y)
return points[y * grid_x + x];
}
UIGrid::~UIGrid()
{
if (tcod_dijkstra) {
delete tcod_dijkstra;
tcod_dijkstra = nullptr;
}
if (tcod_map) {
delete tcod_map;
tcod_map = nullptr;
}
}
PyObjectsEnum UIGrid::derived_type()
{
return PyObjectsEnum::UIGRID;
}
// TCOD integration methods
void UIGrid::syncTCODMap()
{
if (!tcod_map) return;
for (int y = 0; y < grid_y; y++) {
for (int x = 0; x < grid_x; x++) {
const UIGridPoint& point = at(x, y);
tcod_map->setProperties(x, y, point.transparent, point.walkable);
}
}
}
void UIGrid::syncTCODMapCell(int x, int y)
{
if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return;
const UIGridPoint& point = at(x, y);
tcod_map->setProperties(x, y, point.transparent, point.walkable);
}
void UIGrid::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo)
{
if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return;
tcod_map->computeFov(x, y, radius, light_walls, algo);
}
bool UIGrid::isInFOV(int x, int y) const
{
if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return false;
return tcod_map->isInFov(x, y);
}
std::vector<std::pair<int, int>> UIGrid::findPath(int x1, int y1, int x2, int y2, float diagonalCost)
{
std::vector<std::pair<int, int>> path;
if (!tcod_map || x1 < 0 || x1 >= grid_x || y1 < 0 || y1 >= grid_y ||
x2 < 0 || x2 >= grid_x || y2 < 0 || y2 >= grid_y) {
return path;
}
TCODPath tcod_path(tcod_map, diagonalCost);
if (tcod_path.compute(x1, y1, x2, y2)) {
for (int i = 0; i < tcod_path.size(); i++) {
int x, y;
tcod_path.get(i, &x, &y);
path.push_back(std::make_pair(x, y));
}
}
return path;
}
void UIGrid::computeDijkstra(int rootX, int rootY, float diagonalCost)
{
if (!tcod_map || !tcod_dijkstra || rootX < 0 || rootX >= grid_x || rootY < 0 || rootY >= grid_y) return;
// Compute the Dijkstra map from the root position
tcod_dijkstra->compute(rootX, rootY);
}
float UIGrid::getDijkstraDistance(int x, int y) const
{
if (!tcod_dijkstra || x < 0 || x >= grid_x || y < 0 || y >= grid_y) {
return -1.0f; // Invalid position
}
return tcod_dijkstra->getDistance(x, y);
}
std::vector<std::pair<int, int>> UIGrid::getDijkstraPath(int x, int y) const
{
std::vector<std::pair<int, int>> path;
if (!tcod_dijkstra || x < 0 || x >= grid_x || y < 0 || y >= grid_y) {
return path; // Empty path for invalid position
}
// Set the destination
if (tcod_dijkstra->setPath(x, y)) {
// Walk the path and collect points
int px, py;
while (tcod_dijkstra->walk(&px, &py)) {
path.push_back(std::make_pair(px, py));
}
}
return path;
}
// Phase 1 implementations
sf::FloatRect UIGrid::get_bounds() const
{
@ -338,35 +462,53 @@ UIDrawable* UIGrid::click_at(sf::Vector2f point)
int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// Try parsing with PyArgHelpers
int arg_idx = 0;
auto grid_size_result = PyArgHelpers::parseGridSize(args, kwds, &arg_idx);
auto pos_result = PyArgHelpers::parsePosition(args, kwds, &arg_idx);
auto size_result = PyArgHelpers::parseSize(args, kwds, &arg_idx);
// Default values
int grid_x = 0, grid_y = 0;
float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f;
PyObject* textureObj = nullptr;
// Case 1: Got grid size and position from helpers (tuple format)
if (grid_size_result.valid) {
// Check if first argument is a tuple (for tuple-based initialization)
bool has_tuple_first_arg = false;
if (args && PyTuple_Size(args) > 0) {
PyObject* first_arg = PyTuple_GetItem(args, 0);
if (PyTuple_Check(first_arg)) {
has_tuple_first_arg = true;
}
}
// Try tuple-based parsing if we have a tuple as first argument
if (has_tuple_first_arg) {
int arg_idx = 0;
auto grid_size_result = PyArgHelpers::parseGridSize(args, kwds, &arg_idx);
// If grid size parsing failed with an error, report it
if (!grid_size_result.valid) {
if (grid_size_result.error) {
PyErr_SetString(PyExc_TypeError, grid_size_result.error);
} else {
PyErr_SetString(PyExc_TypeError, "Invalid grid size tuple");
}
return -1;
}
// We got a valid grid size
grid_x = grid_size_result.grid_w;
grid_y = grid_size_result.grid_h;
// Set position if we got it
// Try to parse position and size
auto pos_result = PyArgHelpers::parsePosition(args, kwds, &arg_idx);
if (pos_result.valid) {
x = pos_result.x;
y = pos_result.y;
}
// Set size if we got it, otherwise calculate default
auto size_result = PyArgHelpers::parseSize(args, kwds, &arg_idx);
if (size_result.valid) {
w = size_result.w;
h = size_result.h;
} else {
// Default size based on grid dimensions and texture
w = grid_x * 16.0f; // Will be recalculated if texture provided
// Default size based on grid dimensions
w = grid_x * 16.0f;
h = grid_y * 16.0f;
}
@ -380,10 +522,8 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
&textureObj);
Py_DECREF(remaining_args);
}
// Case 2: Traditional format
// Traditional format parsing
else {
PyErr_Clear(); // Clear any errors from helpers
static const char* keywords[] = {
"grid_x", "grid_y", "texture", "pos", "size", "grid_size", nullptr
};
@ -406,7 +546,13 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
if (PyLong_Check(x_obj) && PyLong_Check(y_obj)) {
grid_x = PyLong_AsLong(x_obj);
grid_y = PyLong_AsLong(y_obj);
} else {
PyErr_SetString(PyExc_TypeError, "grid_size must contain integers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "grid_size must be a tuple of two integers");
return -1;
}
}
@ -419,7 +565,13 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
(PyFloat_Check(y_val) || PyLong_Check(y_val))) {
x = PyFloat_Check(x_val) ? PyFloat_AsDouble(x_val) : PyLong_AsLong(x_val);
y = PyFloat_Check(y_val) ? PyFloat_AsDouble(y_val) : PyLong_AsLong(y_val);
} else {
PyErr_SetString(PyExc_TypeError, "pos must contain numbers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of two numbers");
return -1;
}
}
@ -432,7 +584,13 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
(PyFloat_Check(h_val) || PyLong_Check(h_val))) {
w = PyFloat_Check(w_val) ? PyFloat_AsDouble(w_val) : PyLong_AsLong(w_val);
h = PyFloat_Check(h_val) ? PyFloat_AsDouble(h_val) : PyLong_AsLong(h_val);
} else {
PyErr_SetString(PyExc_TypeError, "size must contain numbers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "size must be a tuple of two numbers");
return -1;
}
} else {
// Default size based on grid
@ -440,17 +598,20 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
h = grid_y * 16.0f;
}
}
// Validate grid dimensions
if (grid_x <= 0 || grid_y <= 0) {
PyErr_SetString(PyExc_ValueError, "Grid dimensions must be positive integers");
return -1;
}
// At this point we have x, y, w, h values from either parsing method
// Convert PyObject texture to IndexTexture*
// This requires the texture object to have been initialized similar to UISprite's texture handling
// Convert PyObject texture to shared_ptr<PyTexture>
std::shared_ptr<PyTexture> texture_ptr = nullptr;
// Allow None for texture - use default texture in that case
if (textureObj != Py_None) {
//if (!PyObject_IsInstance(textureObj, (PyObject*)&PyTextureType)) {
// Allow None or NULL for texture - use default texture in that case
if (textureObj && textureObj != Py_None) {
if (!PyObject_IsInstance(textureObj, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture"))) {
PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance or None");
return -1;
@ -458,16 +619,12 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyTextureObject* pyTexture = reinterpret_cast<PyTextureObject*>(textureObj);
texture_ptr = pyTexture->data;
} else {
// Use default texture when None is provided
// Use default texture when None is provided or texture not specified
texture_ptr = McRFPy_API::default_texture;
}
// Initialize UIGrid - texture_ptr will be nullptr if texture was None
//self->data = new UIGrid(grid_x, grid_y, texture, sf::Vector2f(box_x, box_y), sf::Vector2f(box_w, box_h));
//self->data = std::make_shared<UIGrid>(grid_x, grid_y, pyTexture->data,
// sf::Vector2f(box_x, box_y), sf::Vector2f(box_w, box_h));
// Adjust size based on texture if available and size not explicitly set
if (!size_result.valid && texture_ptr) {
if (texture_ptr && w == grid_x * 16.0f && h == grid_y * 16.0f) {
w = grid_x * texture_ptr->sprite_width;
h = grid_y * texture_ptr->sprite_height;
}
@ -719,8 +876,124 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
return 0;
}
// Python API implementations for TCOD functionality
PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static char* kwlist[] = {"x", "y", "radius", "light_walls", "algorithm", NULL};
int x, y, radius = 0;
int light_walls = 1;
int algorithm = FOV_BASIC;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "ii|ipi", kwlist,
&x, &y, &radius, &light_walls, &algorithm)) {
return NULL;
}
self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm);
Py_RETURN_NONE;
}
PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args)
{
int x, y;
if (!PyArg_ParseTuple(args, "ii", &x, &y)) {
return NULL;
}
bool in_fov = self->data->isInFOV(x, y);
return PyBool_FromLong(in_fov);
}
PyObject* UIGrid::py_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static char* kwlist[] = {"x1", "y1", "x2", "y2", "diagonal_cost", NULL};
int x1, y1, x2, y2;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiii|f", kwlist,
&x1, &y1, &x2, &y2, &diagonal_cost)) {
return NULL;
}
std::vector<std::pair<int, int>> path = self->data->findPath(x1, y1, x2, y2, diagonal_cost);
PyObject* path_list = PyList_New(path.size());
if (!path_list) return NULL;
for (size_t i = 0; i < path.size(); i++) {
PyObject* coord = Py_BuildValue("(ii)", path[i].first, path[i].second);
if (!coord) {
Py_DECREF(path_list);
return NULL;
}
PyList_SET_ITEM(path_list, i, coord);
}
return path_list;
}
PyObject* UIGrid::py_compute_dijkstra(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static char* kwlist[] = {"root_x", "root_y", "diagonal_cost", NULL};
int root_x, root_y;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "ii|f", kwlist,
&root_x, &root_y, &diagonal_cost)) {
return NULL;
}
self->data->computeDijkstra(root_x, root_y, diagonal_cost);
Py_RETURN_NONE;
}
PyObject* UIGrid::py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args)
{
int x, y;
if (!PyArg_ParseTuple(args, "ii", &x, &y)) {
return NULL;
}
float distance = self->data->getDijkstraDistance(x, y);
if (distance < 0) {
Py_RETURN_NONE; // Invalid position
}
return PyFloat_FromDouble(distance);
}
PyObject* UIGrid::py_get_dijkstra_path(PyUIGridObject* self, PyObject* args)
{
int x, y;
if (!PyArg_ParseTuple(args, "ii", &x, &y)) {
return NULL;
}
std::vector<std::pair<int, int>> path = self->data->getDijkstraPath(x, y);
PyObject* path_list = PyList_New(path.size());
for (size_t i = 0; i < path.size(); i++) {
PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second);
PyList_SetItem(path_list, i, pos); // Steals reference
}
return path_list;
}
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
"Compute field of view from a position. Args: x, y, radius=0, light_walls=True, algorithm=FOV_BASIC"},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS,
"Check if a cell is in the field of view. Args: x, y"},
{"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS,
"Find A* path between two points. Args: x1, y1, x2, y2, diagonal_cost=1.41"},
{"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS,
"Compute Dijkstra map from root position. Args: root_x, root_y, diagonal_cost=1.41"},
{"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS,
"Get distance from Dijkstra root to position. Args: x, y. Returns float or None if invalid."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS,
"Get path from position to Dijkstra root. Args: x, y. Returns list of (x,y) tuples."},
{NULL, NULL, 0, NULL}
};
@ -731,6 +1004,18 @@ typedef PyUIGridObject PyObjectType;
PyMethodDef UIGrid_all_methods[] = {
UIDRAWABLE_METHODS,
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
"Compute field of view from a position. Args: x, y, radius=0, light_walls=True, algorithm=FOV_BASIC"},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS,
"Check if a cell is in the field of view. Args: x, y"},
{"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS,
"Find A* path between two points. Args: x1, y1, x2, y2, diagonal_cost=1.41"},
{"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS,
"Compute Dijkstra map from root position. Args: root_x, root_y, diagonal_cost=1.41"},
{"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS,
"Get distance from Dijkstra root to position. Args: x, y. Returns float or None if invalid."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS,
"Get path from position to Dijkstra root. Args: x, y. Returns list of (x,y) tuples."},
{NULL} // Sentinel
};