Compare commits

..

3 commits

Author SHA1 Message Date
87444c2fd0 HeightMap: add GRID_MAX limit and input validation
Fixes potential integer overflow and invalid input issues:

- Add GRID_MAX constant (8192) to Common.h for global use
- Validate HeightMap dimensions against GRID_MAX to prevent
  integer overflow in w*h calculations (65536*65536 = 0)
- Add min > max validation for clamp() and normalize()
- Add unit tests for all new validation cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:26:04 -05:00
c095be4b73 HeightMap: core class with scalar operations (closes #193)
Implement the foundational HeightMap class for procedural generation:

- HeightMap(size, fill=0.0) constructor with libtcod backend
- Immutable size property after construction
- Scalar operations returning self for method chaining:
  - fill(value), clear()
  - add_constant(value), scale(factor)
  - clamp(min=0.0, max=1.0), normalize(min=0.0, max=1.0)

Includes procedural generation spec document and unit tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:07:55 -05:00
b32f5af28c UIGridPathfinding: clear and separate A-star and Djikstra path systems 2026-01-10 22:09:45 -05:00
12 changed files with 2650 additions and 510 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,10 @@
#include <SFML/Graphics.hpp> #include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp> #include <SFML/Audio.hpp>
// Maximum dimension for grids, layers, and heightmaps (8192x8192 = 256MB of float data)
// Prevents integer overflow in size calculations and limits memory allocation
constexpr int GRID_MAX = 8192;
#include <vector> #include <vector>
#include <iostream> #include <iostream>
#include <memory> #include <memory>

View file

@ -20,6 +20,8 @@
#include "PyMusic.h" #include "PyMusic.h"
#include "PyKeyboard.h" #include "PyKeyboard.h"
#include "PyMouse.h" #include "PyMouse.h"
#include "UIGridPathfinding.h" // AStarPath and DijkstraMap types
#include "PyHeightMap.h" // Procedural generation heightmap (#193)
#include "McRogueFaceVersion.h" #include "McRogueFaceVersion.h"
#include "GameEngine.h" #include "GameEngine.h"
#include "ImGuiConsole.h" #include "ImGuiConsole.h"
@ -410,6 +412,13 @@ PyObject* PyInit_mcrfpy()
/*mouse state (#186)*/ /*mouse state (#186)*/
&PyMouseType, &PyMouseType,
/*pathfinding result types*/
&mcrfpydef::PyAStarPathType,
&mcrfpydef::PyDijkstraMapType,
/*procedural generation (#192)*/
&mcrfpydef::PyHeightMapType,
nullptr}; nullptr};
// Types that are used internally but NOT exported to module namespace (#189) // Types that are used internally but NOT exported to module namespace (#189)
@ -422,6 +431,9 @@ PyObject* PyInit_mcrfpy()
&PyUICollectionType, &PyUICollectionIterType, &PyUICollectionType, &PyUICollectionIterType,
&PyUIEntityCollectionType, &PyUIEntityCollectionIterType, &PyUIEntityCollectionType, &PyUIEntityCollectionIterType,
/*pathfinding iterator - returned by AStarPath.__iter__() but not directly instantiable*/
&mcrfpydef::PyAStarPathIterType,
nullptr}; nullptr};
// Set up PyWindowType methods and getsetters before PyType_Ready // Set up PyWindowType methods and getsetters before PyType_Ready
@ -431,7 +443,11 @@ PyObject* PyInit_mcrfpy()
// Set up PySceneType methods and getsetters // Set up PySceneType methods and getsetters
PySceneType.tp_methods = PySceneClass::methods; PySceneType.tp_methods = PySceneClass::methods;
PySceneType.tp_getset = PySceneClass::getsetters; PySceneType.tp_getset = PySceneClass::getsetters;
// Set up PyHeightMapType methods and getsetters (#193)
mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods;
mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters;
// Set up weakref support for all types that need it // Set up weakref support for all types that need it
PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist);
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);

View file

@ -10,13 +10,13 @@ static UIGrid* get_grid_from_pyobject(PyObject* obj) {
PyErr_SetString(PyExc_RuntimeError, "Could not find Grid type"); PyErr_SetString(PyExc_RuntimeError, "Could not find Grid type");
return nullptr; return nullptr;
} }
if (!PyObject_IsInstance(obj, (PyObject*)grid_type)) { if (!PyObject_IsInstance(obj, (PyObject*)grid_type)) {
Py_DECREF(grid_type); Py_DECREF(grid_type);
PyErr_SetString(PyExc_TypeError, "First argument must be a Grid object"); PyErr_SetString(PyExc_TypeError, "First argument must be a Grid object");
return nullptr; return nullptr;
} }
Py_DECREF(grid_type); Py_DECREF(grid_type);
PyUIGridObject* pygrid = (PyUIGridObject*)obj; PyUIGridObject* pygrid = (PyUIGridObject*)obj;
return pygrid->data.get(); return pygrid->data.get();
@ -28,18 +28,18 @@ static PyObject* McRFPy_Libtcod::compute_fov(PyObject* self, PyObject* args) {
int x, y, radius; int x, y, radius;
int light_walls = 1; int light_walls = 1;
int algorithm = FOV_BASIC; int algorithm = FOV_BASIC;
if (!PyArg_ParseTuple(args, "Oiii|ii", &grid_obj, &x, &y, &radius, if (!PyArg_ParseTuple(args, "Oiii|ii", &grid_obj, &x, &y, &radius,
&light_walls, &algorithm)) { &light_walls, &algorithm)) {
return NULL; return NULL;
} }
UIGrid* grid = get_grid_from_pyobject(grid_obj); UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL; if (!grid) return NULL;
// Compute FOV using grid's method // Compute FOV using grid's method
grid->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm); grid->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm);
// Return list of visible cells // Return list of visible cells
PyObject* visible_list = PyList_New(0); PyObject* visible_list = PyList_New(0);
for (int gy = 0; gy < grid->grid_h; gy++) { for (int gy = 0; gy < grid->grid_h; gy++) {
@ -51,57 +51,31 @@ static PyObject* McRFPy_Libtcod::compute_fov(PyObject* self, PyObject* args) {
} }
} }
} }
return visible_list;
}
// A* Pathfinding return visible_list;
static PyObject* McRFPy_Libtcod::find_path(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x1, y1, x2, y2;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTuple(args, "Oiiii|f", &grid_obj, &x1, &y1, &x2, &y2, &diagonal_cost)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
// Get path from grid
std::vector<std::pair<int, int>> path = grid->findPath(x1, y1, x2, y2, diagonal_cost);
// Convert to Python list
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;
} }
// Line drawing algorithm // Line drawing algorithm
static PyObject* McRFPy_Libtcod::line(PyObject* self, PyObject* args) { static PyObject* McRFPy_Libtcod::line(PyObject* self, PyObject* args) {
int x1, y1, x2, y2; int x1, y1, x2, y2;
if (!PyArg_ParseTuple(args, "iiii", &x1, &y1, &x2, &y2)) { if (!PyArg_ParseTuple(args, "iiii", &x1, &y1, &x2, &y2)) {
return NULL; return NULL;
} }
// Use TCOD's line algorithm // Use TCOD's line algorithm
TCODLine::init(x1, y1, x2, y2); TCODLine::init(x1, y1, x2, y2);
PyObject* line_list = PyList_New(0); PyObject* line_list = PyList_New(0);
int x, y; int x, y;
// Step through line // Step through line
while (!TCODLine::step(&x, &y)) { while (!TCODLine::step(&x, &y)) {
PyObject* pos = Py_BuildValue("(ii)", x, y); PyObject* pos = Py_BuildValue("(ii)", x, y);
PyList_Append(line_list, pos); PyList_Append(line_list, pos);
Py_DECREF(pos); Py_DECREF(pos);
} }
return line_list; return line_list;
} }
@ -112,80 +86,8 @@ static PyObject* McRFPy_Libtcod::line_iter(PyObject* self, PyObject* args) {
return line(self, args); return line(self, args);
} }
// Dijkstra pathfinding // Pathfinding functions removed - use Grid.find_path() and Grid.get_dijkstra_map() instead
static PyObject* McRFPy_Libtcod::dijkstra_new(PyObject* self, PyObject* args) { // These return AStarPath and DijkstraMap objects (see UIGridPathfinding.h)
PyObject* grid_obj;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTuple(args, "O|f", &grid_obj, &diagonal_cost)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
// For now, just return the grid object since Dijkstra is part of the grid
Py_INCREF(grid_obj);
return grid_obj;
}
static PyObject* McRFPy_Libtcod::dijkstra_compute(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int root_x, root_y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &root_x, &root_y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
grid->computeDijkstra(root_x, root_y);
Py_RETURN_NONE;
}
static PyObject* McRFPy_Libtcod::dijkstra_get_distance(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x, y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
float distance = grid->getDijkstraDistance(x, y);
if (distance < 0) {
Py_RETURN_NONE;
}
return PyFloat_FromDouble(distance);
}
static PyObject* McRFPy_Libtcod::dijkstra_path_to(PyObject* self, PyObject* args) {
PyObject* grid_obj;
int x, y;
if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) {
return NULL;
}
UIGrid* grid = get_grid_from_pyobject(grid_obj);
if (!grid) return NULL;
std::vector<std::pair<int, int>> path = grid->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;
}
// FOV algorithm constants removed - use mcrfpy.FOV enum instead (#114)
// Method definitions // Method definitions
static PyMethodDef libtcodMethods[] = { static PyMethodDef libtcodMethods[] = {
@ -200,18 +102,7 @@ static PyMethodDef libtcodMethods[] = {
" algorithm: FOV algorithm (mcrfpy.FOV.BASIC, mcrfpy.FOV.SHADOW, etc.)\n\n" " algorithm: FOV algorithm (mcrfpy.FOV.BASIC, mcrfpy.FOV.SHADOW, etc.)\n\n"
"Returns:\n" "Returns:\n"
" List of (x, y) tuples for visible cells"}, " List of (x, y) tuples for visible cells"},
{"find_path", McRFPy_Libtcod::find_path, METH_VARARGS,
"find_path(grid, x1, y1, x2, y2, diagonal_cost=1.41)\n\n"
"Find shortest path between two points using A*.\n\n"
"Args:\n"
" grid: Grid object to pathfind on\n"
" x1, y1: Starting position\n"
" x2, y2: Target position\n"
" diagonal_cost: Cost of diagonal movement\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path, or empty list if no path exists"},
{"line", McRFPy_Libtcod::line, METH_VARARGS, {"line", McRFPy_Libtcod::line, METH_VARARGS,
"line(x1, y1, x2, y2)\n\n" "line(x1, y1, x2, y2)\n\n"
"Get cells along a line using Bresenham's algorithm.\n\n" "Get cells along a line using Bresenham's algorithm.\n\n"
@ -220,7 +111,7 @@ static PyMethodDef libtcodMethods[] = {
" x2, y2: Ending position\n\n" " x2, y2: Ending position\n\n"
"Returns:\n" "Returns:\n"
" List of (x, y) tuples along the line"}, " List of (x, y) tuples along the line"},
{"line_iter", McRFPy_Libtcod::line_iter, METH_VARARGS, {"line_iter", McRFPy_Libtcod::line_iter, METH_VARARGS,
"line_iter(x1, y1, x2, y2)\n\n" "line_iter(x1, y1, x2, y2)\n\n"
"Iterate over cells along a line.\n\n" "Iterate over cells along a line.\n\n"
@ -229,41 +120,7 @@ static PyMethodDef libtcodMethods[] = {
" x2, y2: Ending position\n\n" " x2, y2: Ending position\n\n"
"Returns:\n" "Returns:\n"
" Iterator of (x, y) tuples along the line"}, " Iterator of (x, y) tuples along the line"},
{"dijkstra_new", McRFPy_Libtcod::dijkstra_new, METH_VARARGS,
"dijkstra_new(grid, diagonal_cost=1.41)\n\n"
"Create a Dijkstra pathfinding context for a grid.\n\n"
"Args:\n"
" grid: Grid object to use for pathfinding\n"
" diagonal_cost: Cost of diagonal movement\n\n"
"Returns:\n"
" Grid object configured for Dijkstra pathfinding"},
{"dijkstra_compute", McRFPy_Libtcod::dijkstra_compute, METH_VARARGS,
"dijkstra_compute(grid, root_x, root_y)\n\n"
"Compute Dijkstra distance map from root position.\n\n"
"Args:\n"
" grid: Grid object with Dijkstra context\n"
" root_x, root_y: Root position to compute distances from"},
{"dijkstra_get_distance", McRFPy_Libtcod::dijkstra_get_distance, METH_VARARGS,
"dijkstra_get_distance(grid, x, y)\n\n"
"Get distance from root to a position.\n\n"
"Args:\n"
" grid: Grid object with computed Dijkstra map\n"
" x, y: Position to get distance for\n\n"
"Returns:\n"
" Float distance or None if position is invalid/unreachable"},
{"dijkstra_path_to", McRFPy_Libtcod::dijkstra_path_to, METH_VARARGS,
"dijkstra_path_to(grid, x, y)\n\n"
"Get shortest path from position to Dijkstra root.\n\n"
"Args:\n"
" grid: Grid object with computed Dijkstra map\n"
" x, y: Starting position\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path to root"},
{NULL, NULL, 0, NULL} {NULL, NULL, 0, NULL}
}; };
@ -271,7 +128,7 @@ static PyMethodDef libtcodMethods[] = {
static PyModuleDef libtcodModule = { static PyModuleDef libtcodModule = {
PyModuleDef_HEAD_INIT, PyModuleDef_HEAD_INIT,
"mcrfpy.libtcod", "mcrfpy.libtcod",
"TCOD-compatible algorithms for field of view, pathfinding, and line drawing.\n\n" "TCOD-compatible algorithms for field of view and line drawing.\n\n"
"This module provides access to TCOD's algorithms integrated with McRogueFace grids.\n" "This module provides access to TCOD's algorithms integrated with McRogueFace grids.\n"
"Unlike the original TCOD, these functions work directly with Grid objects.\n\n" "Unlike the original TCOD, these functions work directly with Grid objects.\n\n"
"FOV Algorithms (use mcrfpy.FOV enum):\n" "FOV Algorithms (use mcrfpy.FOV enum):\n"
@ -281,12 +138,15 @@ static PyModuleDef libtcodModule = {
" mcrfpy.FOV.PERMISSIVE_0 through PERMISSIVE_8 - Permissive variants\n" " mcrfpy.FOV.PERMISSIVE_0 through PERMISSIVE_8 - Permissive variants\n"
" mcrfpy.FOV.RESTRICTIVE - Most restrictive FOV\n" " mcrfpy.FOV.RESTRICTIVE - Most restrictive FOV\n"
" mcrfpy.FOV.SYMMETRIC_SHADOWCAST - Symmetric shadow casting\n\n" " mcrfpy.FOV.SYMMETRIC_SHADOWCAST - Symmetric shadow casting\n\n"
"Pathfinding:\n"
" Use Grid.find_path() for A* pathfinding (returns AStarPath objects)\n"
" Use Grid.get_dijkstra_map() for Dijkstra pathfinding (returns DijkstraMap objects)\n\n"
"Example:\n" "Example:\n"
" import mcrfpy\n" " import mcrfpy\n"
" from mcrfpy import libtcod\n\n" " from mcrfpy import libtcod\n\n"
" grid = mcrfpy.Grid(50, 50)\n" " grid = mcrfpy.Grid(50, 50)\n"
" visible = libtcod.compute_fov(grid, 25, 25, 10)\n" " visible = libtcod.compute_fov(grid, 25, 25, 10)\n"
" path = libtcod.find_path(grid, 0, 0, 49, 49)", " path = grid.find_path((0, 0), (49, 49)) # Returns AStarPath",
-1, -1,
libtcodMethods libtcodMethods
}; };
@ -297,8 +157,8 @@ PyObject* McRFPy_Libtcod::init_libtcod_module() {
if (m == NULL) { if (m == NULL) {
return NULL; return NULL;
} }
// FOV algorithm constants now provided by mcrfpy.FOV enum (#114) // FOV algorithm constants now provided by mcrfpy.FOV enum (#114)
return m; return m;
} }

302
src/PyHeightMap.cpp Normal file
View file

@ -0,0 +1,302 @@
#include "PyHeightMap.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include <sstream>
// Property definitions
PyGetSetDef PyHeightMap::getsetters[] = {
{"size", (getter)PyHeightMap::get_size, NULL,
MCRF_PROPERTY(size, "Dimensions (width, height) of the heightmap. Read-only."), NULL},
{NULL}
};
// Method definitions
PyMethodDef PyHeightMap::methods[] = {
{"fill", (PyCFunction)PyHeightMap::fill, METH_VARARGS,
MCRF_METHOD(HeightMap, fill,
MCRF_SIG("(value: float)", "HeightMap"),
MCRF_DESC("Set all cells to the specified value."),
MCRF_ARGS_START
MCRF_ARG("value", "The value to set for all cells")
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{"clear", (PyCFunction)PyHeightMap::clear, METH_NOARGS,
MCRF_METHOD(HeightMap, clear,
MCRF_SIG("()", "HeightMap"),
MCRF_DESC("Set all cells to 0.0. Equivalent to fill(0.0)."),
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{"add_constant", (PyCFunction)PyHeightMap::add_constant, METH_VARARGS,
MCRF_METHOD(HeightMap, add_constant,
MCRF_SIG("(value: float)", "HeightMap"),
MCRF_DESC("Add a constant value to every cell."),
MCRF_ARGS_START
MCRF_ARG("value", "The value to add to each cell")
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{"scale", (PyCFunction)PyHeightMap::scale, METH_VARARGS,
MCRF_METHOD(HeightMap, scale,
MCRF_SIG("(factor: float)", "HeightMap"),
MCRF_DESC("Multiply every cell by a factor."),
MCRF_ARGS_START
MCRF_ARG("factor", "The multiplier for each cell")
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{"clamp", (PyCFunction)PyHeightMap::clamp, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(HeightMap, clamp,
MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"),
MCRF_DESC("Clamp all values to the specified range."),
MCRF_ARGS_START
MCRF_ARG("min", "Minimum value (default 0.0)")
MCRF_ARG("max", "Maximum value (default 1.0)")
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{"normalize", (PyCFunction)PyHeightMap::normalize, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(HeightMap, normalize,
MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"),
MCRF_DESC("Linearly rescale values so the current minimum becomes min and current maximum becomes max."),
MCRF_ARGS_START
MCRF_ARG("min", "Target minimum value (default 0.0)")
MCRF_ARG("max", "Target maximum value (default 1.0)")
MCRF_RETURNS("HeightMap: self, for method chaining")
)},
{NULL}
};
// Constructor
PyObject* PyHeightMap::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
{
PyHeightMapObject* self = (PyHeightMapObject*)type->tp_alloc(type, 0);
if (self) {
self->heightmap = nullptr;
}
return (PyObject*)self;
}
int PyHeightMap::init(PyHeightMapObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"size", "fill", nullptr};
PyObject* size_obj = nullptr;
float fill_value = 0.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(keywords),
&size_obj, &fill_value)) {
return -1;
}
// Parse size tuple
if (!PyTuple_Check(size_obj) || PyTuple_Size(size_obj) != 2) {
PyErr_SetString(PyExc_TypeError, "size must be a tuple of (width, height)");
return -1;
}
int width = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 0));
int height = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 1));
if (PyErr_Occurred()) {
return -1;
}
if (width <= 0 || height <= 0) {
PyErr_SetString(PyExc_ValueError, "width and height must be positive integers");
return -1;
}
if (width > GRID_MAX || height > GRID_MAX) {
PyErr_Format(PyExc_ValueError,
"HeightMap dimensions cannot exceed %d (got %dx%d)",
GRID_MAX, width, height);
return -1;
}
// Clean up any existing heightmap
if (self->heightmap) {
TCOD_heightmap_delete(self->heightmap);
}
// Create new libtcod heightmap
self->heightmap = TCOD_heightmap_new(width, height);
if (!self->heightmap) {
PyErr_SetString(PyExc_MemoryError, "Failed to allocate heightmap");
return -1;
}
// Fill with initial value if not zero
if (fill_value != 0.0f) {
// libtcod's TCOD_heightmap_add adds to all cells, so we use it after clear
TCOD_heightmap_clear(self->heightmap);
TCOD_heightmap_add(self->heightmap, fill_value);
}
return 0;
}
void PyHeightMap::dealloc(PyHeightMapObject* self)
{
if (self->heightmap) {
TCOD_heightmap_delete(self->heightmap);
self->heightmap = nullptr;
}
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* PyHeightMap::repr(PyObject* obj)
{
PyHeightMapObject* self = (PyHeightMapObject*)obj;
std::ostringstream ss;
if (self->heightmap) {
ss << "<HeightMap (" << self->heightmap->w << " x " << self->heightmap->h << ")>";
} else {
ss << "<HeightMap (uninitialized)>";
}
return PyUnicode_FromString(ss.str().c_str());
}
// Property: size
PyObject* PyHeightMap::get_size(PyHeightMapObject* self, void* closure)
{
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
return Py_BuildValue("(ii)", self->heightmap->w, self->heightmap->h);
}
// Method: fill(value) -> HeightMap
PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args)
{
float value;
if (!PyArg_ParseTuple(args, "f", &value)) {
return nullptr;
}
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
// Clear and then add the value (libtcod doesn't have a direct "set all" function)
TCOD_heightmap_clear(self->heightmap);
if (value != 0.0f) {
TCOD_heightmap_add(self->heightmap, value);
}
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
// Method: clear() -> HeightMap
PyObject* PyHeightMap::clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args))
{
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
TCOD_heightmap_clear(self->heightmap);
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
// Method: add_constant(value) -> HeightMap
PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args)
{
float value;
if (!PyArg_ParseTuple(args, "f", &value)) {
return nullptr;
}
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
TCOD_heightmap_add(self->heightmap, value);
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
// Method: scale(factor) -> HeightMap
PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args)
{
float factor;
if (!PyArg_ParseTuple(args, "f", &factor)) {
return nullptr;
}
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
TCOD_heightmap_scale(self->heightmap, factor);
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
// Method: clamp(min=0.0, max=1.0) -> HeightMap
PyObject* PyHeightMap::clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"min", "max", nullptr};
float min_val = 0.0f;
float max_val = 1.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast<char**>(keywords),
&min_val, &max_val)) {
return nullptr;
}
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
if (min_val > max_val) {
PyErr_SetString(PyExc_ValueError, "min must be less than or equal to max");
return nullptr;
}
TCOD_heightmap_clamp(self->heightmap, min_val, max_val);
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
// Method: normalize(min=0.0, max=1.0) -> HeightMap
PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"min", "max", nullptr};
float min_val = 0.0f;
float max_val = 1.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast<char**>(keywords),
&min_val, &max_val)) {
return nullptr;
}
if (!self->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
if (min_val > max_val) {
PyErr_SetString(PyExc_ValueError, "min must be less than or equal to max");
return nullptr;
}
TCOD_heightmap_normalize(self->heightmap, min_val, max_val);
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}

67
src/PyHeightMap.h Normal file
View file

@ -0,0 +1,67 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <libtcod.h>
// Forward declaration
class PyHeightMap;
// Python object structure
typedef struct {
PyObject_HEAD
TCOD_heightmap_t* heightmap; // libtcod heightmap pointer
} PyHeightMapObject;
class PyHeightMap
{
public:
// Python type interface
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyHeightMapObject* self);
static PyObject* repr(PyObject* obj);
// Properties
static PyObject* get_size(PyHeightMapObject* self, void* closure);
// Scalar operations (all return self for chaining)
static PyObject* fill(PyHeightMapObject* self, PyObject* args);
static PyObject* clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args));
static PyObject* add_constant(PyHeightMapObject* self, PyObject* args);
static PyObject* scale(PyHeightMapObject* self, PyObject* args);
static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
namespace mcrfpydef {
static PyTypeObject PyHeightMapType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.HeightMap",
.tp_basicsize = sizeof(PyHeightMapObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyHeightMap::dealloc,
.tp_repr = PyHeightMap::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"HeightMap(size: tuple[int, int], fill: float = 0.0)\n\n"
"A 2D grid of float values for procedural generation.\n\n"
"HeightMap is the universal canvas for procedural generation. It stores "
"float values that can be manipulated, combined, and applied to Grid and "
"Layer objects.\n\n"
"Args:\n"
" size: (width, height) dimensions of the heightmap. Immutable after creation.\n"
" fill: Initial value for all cells. Default 0.0.\n\n"
"Example:\n"
" hmap = mcrfpy.HeightMap((100, 100))\n"
" hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0)\n"
),
.tp_methods = nullptr, // Set in McRFPy_API.cpp before PyType_Ready
.tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready
.tp_init = (initproc)PyHeightMap::init,
.tp_new = PyHeightMap::pynew,
};
}

View file

@ -3,6 +3,7 @@
#include "McRFPy_API.h" #include "McRFPy_API.h"
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include <libtcod.h>
#include "PyObjectUtils.h" #include "PyObjectUtils.h"
#include "PyVector.h" #include "PyVector.h"
#include "PythonObjectCache.h" #include "PythonObjectCache.h"
@ -749,39 +750,45 @@ PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kw
PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to compute paths"); PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to compute paths");
return NULL; return NULL;
} }
// Get current position // Get current position
int current_x = static_cast<int>(self->data->position.x); int current_x = static_cast<int>(self->data->position.x);
int current_y = static_cast<int>(self->data->position.y); int current_y = static_cast<int>(self->data->position.y);
// Validate target position // Validate target position
auto grid = self->data->grid; auto grid = self->data->grid;
if (target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) { if (target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) {
PyErr_Format(PyExc_ValueError, "Target position (%d, %d) is out of grid bounds (0-%d, 0-%d)", PyErr_Format(PyExc_ValueError, "Target position (%d, %d) is out of grid bounds (0-%d, 0-%d)",
target_x, target_y, grid->grid_w - 1, grid->grid_h - 1); target_x, target_y, grid->grid_w - 1, grid->grid_h - 1);
return NULL; return NULL;
} }
// Use the grid's Dijkstra implementation // Use A* pathfinding via temporary TCODPath
grid->computeDijkstra(current_x, current_y); TCODPath tcod_path(grid->getTCODMap(), 1.41f);
auto path = grid->getDijkstraPath(target_x, target_y); if (!tcod_path.compute(current_x, current_y, target_x, target_y)) {
// No path found - return empty list
return PyList_New(0);
}
// Convert path to Python list of tuples // Convert path to Python list of tuples
PyObject* path_list = PyList_New(path.size()); PyObject* path_list = PyList_New(tcod_path.size());
if (!path_list) return PyErr_NoMemory(); if (!path_list) return PyErr_NoMemory();
for (size_t i = 0; i < path.size(); ++i) { for (int i = 0; i < tcod_path.size(); ++i) {
int px, py;
tcod_path.get(i, &px, &py);
PyObject* coord_tuple = PyTuple_New(2); PyObject* coord_tuple = PyTuple_New(2);
if (!coord_tuple) { if (!coord_tuple) {
Py_DECREF(path_list); Py_DECREF(path_list);
return PyErr_NoMemory(); return PyErr_NoMemory();
} }
PyTuple_SetItem(coord_tuple, 0, PyLong_FromLong(path[i].first)); PyTuple_SetItem(coord_tuple, 0, PyLong_FromLong(px));
PyTuple_SetItem(coord_tuple, 1, PyLong_FromLong(path[i].second)); PyTuple_SetItem(coord_tuple, 1, PyLong_FromLong(py));
PyList_SetItem(path_list, i, coord_tuple); PyList_SetItem(path_list, i, coord_tuple);
} }
return path_list; return path_list;
} }

View file

@ -1,4 +1,5 @@
#include "UIGrid.h" #include "UIGrid.h"
#include "UIGridPathfinding.h" // New pathfinding API
#include "GameEngine.h" #include "GameEngine.h"
#include "McRFPy_API.h" #include "McRFPy_API.h"
#include "PythonObjectCache.h" #include "PythonObjectCache.h"
@ -17,7 +18,7 @@
UIGrid::UIGrid() UIGrid::UIGrid()
: grid_w(0), grid_h(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr), : grid_w(0), grid_h(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), fill_color(8, 8, 8, 255), tcod_map(nullptr),
perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10), perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10),
use_chunks(false) // Default to omniscient view use_chunks(false) // Default to omniscient view
{ {
@ -49,7 +50,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
: grid_w(gx), grid_h(gy), : grid_w(gx), grid_h(gy),
zoom(1.0f), zoom(1.0f),
ptex(_ptex), ptex(_ptex),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), fill_color(8, 8, 8, 255), tcod_map(nullptr),
perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10), perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10),
use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids
{ {
@ -84,14 +85,10 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
// textures are upside-down inside renderTexture // textures are upside-down inside renderTexture
output.setTexture(renderTexture.getTexture()); output.setTexture(renderTexture.getTexture());
// Create TCOD map // Create TCOD map for FOV and as source for pathfinding
tcod_map = new TCODMap(gx, gy); tcod_map = new TCODMap(gx, gy);
// Note: DijkstraMap objects are created on-demand via get_dijkstra_map()
// Create TCOD dijkstra pathfinder // A* paths are computed on-demand via find_path()
tcod_dijkstra = new TCODDijkstra(tcod_map);
// Create TCOD A* pathfinder
tcod_path = new TCODPath(tcod_map);
// #123 - Initialize storage based on grid size // #123 - Initialize storage based on grid size
if (use_chunks) { if (use_chunks) {
@ -368,14 +365,9 @@ UIGridPoint& UIGrid::at(int x, int y)
UIGrid::~UIGrid() UIGrid::~UIGrid()
{ {
if (tcod_path) { // Clear Dijkstra maps first (they reference tcod_map)
delete tcod_path; dijkstra_maps.clear();
tcod_path = nullptr;
}
if (tcod_dijkstra) {
delete tcod_dijkstra;
tcod_dijkstra = nullptr;
}
if (tcod_map) { if (tcod_map) {
delete tcod_map; delete tcod_map;
tcod_map = nullptr; tcod_map = nullptr;
@ -476,98 +468,9 @@ bool UIGrid::isInFOV(int x, int y) const
return tcod_map->isInFov(x, y); return tcod_map->isInFov(x, y);
} }
std::vector<std::pair<int, int>> UIGrid::findPath(int x1, int y1, int x2, int y2, float diagonalCost) // Pathfinding methods moved to UIGridPathfinding.cpp
{ // - Grid.find_path() returns AStarPath objects
std::vector<std::pair<int, int>> path; // - Grid.get_dijkstra_map() returns DijkstraMap objects (cached)
if (!tcod_map || x1 < 0 || x1 >= grid_w || y1 < 0 || y1 >= grid_h ||
x2 < 0 || x2 >= grid_w || y2 < 0 || y2 >= grid_h) {
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_w || rootY < 0 || rootY >= grid_h) 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_w || y < 0 || y >= grid_h) {
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_w || y < 0 || y >= grid_h) {
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;
}
// A* pathfinding implementation
std::vector<std::pair<int, int>> UIGrid::computeAStarPath(int x1, int y1, int x2, int y2, float diagonalCost)
{
std::vector<std::pair<int, int>> path;
// Validate inputs
if (!tcod_map || !tcod_path ||
x1 < 0 || x1 >= grid_w || y1 < 0 || y1 >= grid_h ||
x2 < 0 || x2 >= grid_w || y2 < 0 || y2 >= grid_h) {
return path; // Return empty path
}
// Set diagonal cost (TCODPath doesn't take it as parameter to compute)
// Instead, diagonal cost is set during TCODPath construction
// For now, we'll use the default diagonal cost from the constructor
// Compute the path
bool success = tcod_path->compute(x1, y1, x2, y2);
if (success) {
// Get the computed path
int pathSize = tcod_path->size();
path.reserve(pathSize);
// TCOD path includes the starting position, so we start from index 0
for (int i = 0; i < pathSize; i++) {
int px, py;
tcod_path->get(i, &px, &py);
path.push_back(std::make_pair(px, py));
}
}
return path;
}
// Phase 1 implementations // Phase 1 implementations
sf::FloatRect UIGrid::get_bounds() const sf::FloatRect UIGrid::get_bounds() const
@ -1453,128 +1356,9 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k
return PyBool_FromLong(in_fov); return PyBool_FromLong(in_fov);
} }
PyObject* UIGrid::py_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) // Old pathfinding Python methods removed - see UIGridPathfinding.cpp for new implementation
{ // Grid.find_path() now returns AStarPath objects
static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; // Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root)
PyObject* start_obj = NULL;
PyObject* end_obj = NULL;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast<char**>(kwlist),
&start_obj, &end_obj, &diagonal_cost)) {
return NULL;
}
int x1, y1, x2, y2;
if (!PyPosition_FromObjectInt(start_obj, &x1, &y1)) {
return NULL;
}
if (!PyPosition_FromObjectInt(end_obj, &x2, &y2)) {
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 const char* kwlist[] = {"root", "diagonal_cost", NULL};
PyObject* root_obj = NULL;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(kwlist),
&root_obj, &diagonal_cost)) {
return NULL;
}
int root_x, root_y;
if (!PyPosition_FromObjectInt(root_obj, &root_x, &root_y)) {
return NULL;
}
self->data->computeDijkstra(root_x, root_y, diagonal_cost);
Py_RETURN_NONE;
}
PyObject* UIGrid::py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
int x, y;
if (!PyPosition_ParseInt(args, kwds, &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, PyObject* kwds)
{
int x, y;
if (!PyPosition_ParseInt(args, kwds, &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;
}
PyObject* UIGrid::py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL};
PyObject* start_obj = NULL;
PyObject* end_obj = NULL;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast<char**>(kwlist),
&start_obj, &end_obj, &diagonal_cost)) {
return NULL;
}
int x1, y1, x2, y2;
if (!PyPosition_FromObjectInt(start_obj, &x1, &y1)) {
return NULL;
}
if (!PyPosition_FromObjectInt(end_obj, &x2, &y2)) {
return NULL;
}
// Compute A* path
std::vector<std::pair<int, int>> path = self->data->computeAStarPath(x1, y1, x2, y2, diagonal_cost);
// Convert to Python list
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;
}
// #147 - Layer system Python API // #147 - Layer system Python API
PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
@ -1925,51 +1709,32 @@ PyMethodDef UIGrid::methods[] = {
"Returns:\n" "Returns:\n"
" True if the cell is visible, False otherwise\n\n" " True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."}, "Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n"
"Find A* path between two points.\n\n"
"Args:\n"
" start: Starting position as (x, y) tuple, list, or Vector\n"
" end: Target position as (x, y) tuple, list, or Vector\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path, empty list if no path exists\n\n"
"Uses A* algorithm with walkability from grid cells."},
{"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS,
"compute_dijkstra(root, diagonal_cost: float = 1.41) -> None\n\n"
"Compute Dijkstra map from root position.\n\n"
"Args:\n"
" root: Root position as (x, y) tuple, list, or Vector\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Precomputes distances from all reachable cells to the root.\n"
"Use get_dijkstra_distance() and get_dijkstra_path() to query results.\n"
"Useful for multiple entities pathfinding to the same target."},
{"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_distance(pos) -> Optional[float]\n\n"
"Get distance from Dijkstra root to position.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" Distance as float, or None if position is unreachable or invalid\n\n"
"Must call compute_dijkstra() first."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_path(pos) -> List[Tuple[int, int]]\n\n"
"Get path from position to Dijkstra root.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" List of (x, y) tuples representing path to root, empty if unreachable\n\n"
"Must call compute_dijkstra() first. Path includes start but not root position."},
{"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS,
"compute_astar_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n"
"Compute A* path between two points.\n\n" "Compute A* path between two points.\n\n"
"Args:\n" "Args:\n"
" start: Starting position as (x, y) tuple, list, or Vector\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as (x, y) tuple, list, or Vector\n" " end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n" "Returns:\n"
" List of (x, y) tuples representing the path, empty list if no path exists\n\n" " AStarPath object if path exists, None otherwise.\n\n"
"Alternative A* implementation. Prefer find_path() for consistency."}, "The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
"changing grid walkability to invalidate the cache."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS,
"add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer"}, "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer"},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
@ -2021,51 +1786,32 @@ PyMethodDef UIGrid_all_methods[] = {
"Returns:\n" "Returns:\n"
" True if the cell is visible, False otherwise\n\n" " True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."}, "Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n"
"Find A* path between two points.\n\n"
"Args:\n"
" start: Starting position as (x, y) tuple, list, or Vector\n"
" end: Target position as (x, y) tuple, list, or Vector\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" List of (x, y) tuples representing the path, empty list if no path exists\n\n"
"Uses A* algorithm with walkability from grid cells."},
{"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS,
"compute_dijkstra(root, diagonal_cost: float = 1.41) -> None\n\n"
"Compute Dijkstra map from root position.\n\n"
"Args:\n"
" root: Root position as (x, y) tuple, list, or Vector\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Precomputes distances from all reachable cells to the root.\n"
"Use get_dijkstra_distance() and get_dijkstra_path() to query results.\n"
"Useful for multiple entities pathfinding to the same target."},
{"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_distance(pos) -> Optional[float]\n\n"
"Get distance from Dijkstra root to position.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" Distance as float, or None if position is unreachable or invalid\n\n"
"Must call compute_dijkstra() first."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_path(pos) -> List[Tuple[int, int]]\n\n"
"Get path from position to Dijkstra root.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" List of (x, y) tuples representing path to root, empty if unreachable\n\n"
"Must call compute_dijkstra() first. Path includes start but not root position."},
{"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS,
"compute_astar_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n"
"Compute A* path between two points.\n\n" "Compute A* path between two points.\n\n"
"Args:\n" "Args:\n"
" start: Starting position as (x, y) tuple, list, or Vector\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as (x, y) tuple, list, or Vector\n" " end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n" "Returns:\n"
" List of (x, y) tuples representing the path, empty list if no path exists\n\n" " AStarPath object if path exists, None otherwise.\n\n"
"Alternative A* implementation. Prefer find_path() for consistency."}, "The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
"changing grid walkability to invalidate the cache."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS,
"add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer\n\n" "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer\n\n"
"Add a new layer to the grid.\n\n" "Add a new layer to the grid.\n\n"

View file

@ -8,6 +8,8 @@
#include <libtcod.h> #include <libtcod.h>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <map>
#include <memory>
#include "PyCallable.h" #include "PyCallable.h"
#include "PyTexture.h" #include "PyTexture.h"
@ -25,6 +27,9 @@
#include "SpatialHash.h" #include "SpatialHash.h"
#include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid) #include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid)
// Forward declaration for pathfinding
class DijkstraMap;
class UIGrid: public UIDrawable class UIGrid: public UIDrawable
{ {
private: private:
@ -33,10 +38,13 @@ private:
static constexpr int DEFAULT_CELL_WIDTH = 16; static constexpr int DEFAULT_CELL_WIDTH = 16;
static constexpr int DEFAULT_CELL_HEIGHT = 16; static constexpr int DEFAULT_CELL_HEIGHT = 16;
TCODMap* tcod_map; // TCOD map for FOV and pathfinding TCODMap* tcod_map; // TCOD map for FOV and pathfinding
TCODDijkstra* tcod_dijkstra; // Dijkstra pathfinding
TCODPath* tcod_path; // A* pathfinding
mutable std::mutex fov_mutex; // Mutex for thread-safe FOV operations mutable std::mutex fov_mutex; // Mutex for thread-safe FOV operations
public:
// Dijkstra map cache - keyed by root position
// Public so UIGridPathfinding can access it
std::map<std::pair<int,int>, std::shared_ptr<DijkstraMap>> dijkstra_maps;
public: public:
UIGrid(); UIGrid();
//UIGrid(int, int, IndexTexture*, float, float, float, float); //UIGrid(int, int, IndexTexture*, float, float, float, float);
@ -54,15 +62,12 @@ public:
void syncTCODMapCell(int x, int y); // Sync a single cell to TCOD map void syncTCODMapCell(int x, int y); // Sync a single cell to TCOD map
void computeFOV(int x, int y, int radius, bool light_walls = true, TCOD_fov_algorithm_t algo = FOV_BASIC); void computeFOV(int x, int y, int radius, bool light_walls = true, TCOD_fov_algorithm_t algo = FOV_BASIC);
bool isInFOV(int x, int y) const; bool isInFOV(int x, int y) const;
TCODMap* getTCODMap() const { return tcod_map; } // Access for pathfinding
// Pathfinding methods // Pathfinding - new API creates AStarPath/DijkstraMap objects
std::vector<std::pair<int, int>> findPath(int x1, int y1, int x2, int y2, float diagonalCost = 1.41f); // See UIGridPathfinding.h for the new pathfinding API
void computeDijkstra(int rootX, int rootY, float diagonalCost = 1.41f); // Grid.find_path() now returns AStarPath objects
float getDijkstraDistance(int x, int y) const; // Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root position)
std::vector<std::pair<int, int>> getDijkstraPath(int x, int y) const;
// A* pathfinding methods
std::vector<std::pair<int, int>> computeAStarPath(int x1, int y1, int x2, int y2, float diagonalCost = 1.41f);
// Phase 1 virtual method implementations // Phase 1 virtual method implementations
sf::FloatRect get_bounds() const override; sf::FloatRect get_bounds() const override;
@ -167,11 +172,10 @@ public:
static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); // Pathfinding methods moved to UIGridPathfinding.cpp
static PyObject* py_compute_dijkstra(PyUIGridObject* self, PyObject* args, PyObject* kwds); // py_find_path -> UIGridPathfinding::Grid_find_path (returns AStarPath)
static PyObject* py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args, PyObject* kwds); // py_get_dijkstra_map -> UIGridPathfinding::Grid_get_dijkstra_map (returns DijkstraMap)
static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); // py_clear_dijkstra_maps -> UIGridPathfinding::Grid_clear_dijkstra_maps
static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115
static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169 static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169

688
src/UIGridPathfinding.cpp Normal file
View file

@ -0,0 +1,688 @@
#include "UIGridPathfinding.h"
#include "UIGrid.h"
#include "UIEntity.h"
#include "PyVector.h"
#include "McRFPy_API.h"
//=============================================================================
// DijkstraMap Implementation
//=============================================================================
DijkstraMap::DijkstraMap(TCODMap* map, int root_x, int root_y, float diag_cost)
: tcod_map(map)
, root(root_x, root_y)
, diagonal_cost(diag_cost)
{
tcod_dijkstra = new TCODDijkstra(tcod_map, diagonal_cost);
tcod_dijkstra->compute(root_x, root_y); // Compute immediately at creation
}
DijkstraMap::~DijkstraMap() {
if (tcod_dijkstra) {
delete tcod_dijkstra;
tcod_dijkstra = nullptr;
}
}
float DijkstraMap::getDistance(int x, int y) const {
if (!tcod_dijkstra) return -1.0f;
return tcod_dijkstra->getDistance(x, y);
}
std::vector<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
std::vector<sf::Vector2i> path;
if (!tcod_dijkstra) return path;
if (tcod_dijkstra->setPath(x, y)) {
int px, py;
while (tcod_dijkstra->walk(&px, &py)) {
path.push_back(sf::Vector2i(px, py));
}
}
return path;
}
sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const {
if (!tcod_dijkstra) {
if (valid) *valid = false;
return sf::Vector2i(-1, -1);
}
if (!tcod_dijkstra->setPath(x, y)) {
if (valid) *valid = false;
return sf::Vector2i(-1, -1);
}
int px, py;
if (tcod_dijkstra->walk(&px, &py)) {
if (valid) *valid = true;
return sf::Vector2i(px, py);
}
// At root or no path
if (valid) *valid = false;
return sf::Vector2i(-1, -1);
}
//=============================================================================
// Helper Functions
//=============================================================================
bool UIGridPathfinding::ExtractPosition(PyObject* obj, int* x, int* y,
UIGrid* expected_grid,
const char* arg_name) {
// Get types from module to avoid static type instance issues
PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
// Check if it's an Entity
if (entity_type && PyObject_IsInstance(obj, entity_type)) {
Py_DECREF(entity_type);
Py_XDECREF(vector_type);
auto* entity = (PyUIEntityObject*)obj;
if (!entity->data) {
PyErr_Format(PyExc_RuntimeError,
"%s: Entity has no data", arg_name);
return false;
}
if (!entity->data->grid) {
PyErr_Format(PyExc_RuntimeError,
"%s: Entity is not attached to any grid", arg_name);
return false;
}
if (expected_grid && entity->data->grid.get() != expected_grid) {
PyErr_Format(PyExc_RuntimeError,
"%s: Entity belongs to a different grid", arg_name);
return false;
}
*x = static_cast<int>(entity->data->position.x);
*y = static_cast<int>(entity->data->position.y);
return true;
}
Py_XDECREF(entity_type);
// Check if it's a Vector
if (vector_type && PyObject_IsInstance(obj, vector_type)) {
Py_DECREF(vector_type);
auto* vec = (PyVectorObject*)obj;
*x = static_cast<int>(vec->data.x);
*y = static_cast<int>(vec->data.y);
return true;
}
Py_XDECREF(vector_type);
// Try tuple/list
if (PySequence_Check(obj) && PySequence_Size(obj) == 2) {
PyObject* x_obj = PySequence_GetItem(obj, 0);
PyObject* y_obj = PySequence_GetItem(obj, 1);
bool ok = false;
if (x_obj && y_obj && PyNumber_Check(x_obj) && PyNumber_Check(y_obj)) {
PyObject* x_long = PyNumber_Long(x_obj);
PyObject* y_long = PyNumber_Long(y_obj);
if (x_long && y_long) {
*x = PyLong_AsLong(x_long);
*y = PyLong_AsLong(y_long);
ok = !PyErr_Occurred();
Py_DECREF(x_long);
Py_DECREF(y_long);
}
Py_XDECREF(x_long);
Py_XDECREF(y_long);
}
Py_XDECREF(x_obj);
Py_XDECREF(y_obj);
if (ok) return true;
}
PyErr_Format(PyExc_TypeError,
"%s: expected Vector, Entity, or (x, y) tuple", arg_name);
return false;
}
//=============================================================================
// AStarPath Python Methods
//=============================================================================
PyObject* UIGridPathfinding::AStarPath_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyAStarPathObject* self = (PyAStarPathObject*)type->tp_alloc(type, 0);
if (self) {
new (&self->path) std::vector<sf::Vector2i>(); // Placement new
self->current_index = 0;
self->origin = sf::Vector2i(0, 0);
self->destination = sf::Vector2i(0, 0);
}
return (PyObject*)self;
}
int UIGridPathfinding::AStarPath_init(PyAStarPathObject* self, PyObject* args, PyObject* kwds) {
// AStarPath should not be created directly from Python
PyErr_SetString(PyExc_TypeError,
"AStarPath cannot be instantiated directly. Use Grid.find_path() instead.");
return -1;
}
void UIGridPathfinding::AStarPath_dealloc(PyAStarPathObject* self) {
self->path.~vector(); // Explicitly destroy
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* UIGridPathfinding::AStarPath_repr(PyAStarPathObject* self) {
size_t remaining = self->path.size() - self->current_index;
return PyUnicode_FromFormat("<AStarPath from (%d,%d) to (%d,%d), %zu steps remaining>",
self->origin.x, self->origin.y,
self->destination.x, self->destination.y,
remaining);
}
PyObject* UIGridPathfinding::AStarPath_walk(PyAStarPathObject* self, PyObject* args) {
if (self->current_index >= self->path.size()) {
PyErr_SetString(PyExc_IndexError, "Path exhausted - no more steps");
return NULL;
}
sf::Vector2i pos = self->path[self->current_index++];
return PyVector(sf::Vector2f(static_cast<float>(pos.x), static_cast<float>(pos.y))).pyObject();
}
PyObject* UIGridPathfinding::AStarPath_peek(PyAStarPathObject* self, PyObject* args) {
if (self->current_index >= self->path.size()) {
PyErr_SetString(PyExc_IndexError, "Path exhausted - no more steps");
return NULL;
}
sf::Vector2i pos = self->path[self->current_index];
return PyVector(sf::Vector2f(static_cast<float>(pos.x), static_cast<float>(pos.y))).pyObject();
}
PyObject* UIGridPathfinding::AStarPath_get_origin(PyAStarPathObject* self, void* closure) {
return PyVector(sf::Vector2f(static_cast<float>(self->origin.x),
static_cast<float>(self->origin.y))).pyObject();
}
PyObject* UIGridPathfinding::AStarPath_get_destination(PyAStarPathObject* self, void* closure) {
return PyVector(sf::Vector2f(static_cast<float>(self->destination.x),
static_cast<float>(self->destination.y))).pyObject();
}
PyObject* UIGridPathfinding::AStarPath_get_remaining(PyAStarPathObject* self, void* closure) {
size_t remaining = self->path.size() - self->current_index;
return PyLong_FromSize_t(remaining);
}
Py_ssize_t UIGridPathfinding::AStarPath_len(PyAStarPathObject* self) {
return static_cast<Py_ssize_t>(self->path.size() - self->current_index);
}
int UIGridPathfinding::AStarPath_bool(PyObject* obj) {
PyAStarPathObject* self = (PyAStarPathObject*)obj;
return self->current_index < self->path.size() ? 1 : 0;
}
PyObject* UIGridPathfinding::AStarPath_iter(PyAStarPathObject* self) {
// Create iterator object
mcrfpydef::PyAStarPathIterObject* iter = PyObject_New(
mcrfpydef::PyAStarPathIterObject, &mcrfpydef::PyAStarPathIterType);
if (!iter) return NULL;
Py_INCREF(self);
iter->path = self;
iter->iter_index = self->current_index;
return (PyObject*)iter;
}
// Iterator implementation
static void AStarPathIter_dealloc(mcrfpydef::PyAStarPathIterObject* self) {
Py_XDECREF(self->path);
Py_TYPE(self)->tp_free((PyObject*)self);
}
static PyObject* AStarPathIter_next(mcrfpydef::PyAStarPathIterObject* self) {
if (!self->path || self->iter_index >= self->path->path.size()) {
return NULL; // StopIteration
}
sf::Vector2i pos = self->path->path[self->iter_index++];
// Note: Iterating is consuming for this iterator
self->path->current_index = self->iter_index;
return PyVector(sf::Vector2f(static_cast<float>(pos.x), static_cast<float>(pos.y))).pyObject();
}
static PyObject* AStarPathIter_iter(mcrfpydef::PyAStarPathIterObject* self) {
Py_INCREF(self);
return (PyObject*)self;
}
//=============================================================================
// DijkstraMap Python Methods
//=============================================================================
PyObject* UIGridPathfinding::DijkstraMap_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyDijkstraMapObject* self = (PyDijkstraMapObject*)type->tp_alloc(type, 0);
if (self) {
new (&self->data) std::shared_ptr<DijkstraMap>();
}
return (PyObject*)self;
}
int UIGridPathfinding::DijkstraMap_init(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) {
PyErr_SetString(PyExc_TypeError,
"DijkstraMap cannot be instantiated directly. Use Grid.get_dijkstra_map() instead.");
return -1;
}
void UIGridPathfinding::DijkstraMap_dealloc(PyDijkstraMapObject* self) {
self->data.~shared_ptr();
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* UIGridPathfinding::DijkstraMap_repr(PyDijkstraMapObject* self) {
if (!self->data) {
return PyUnicode_FromString("<DijkstraMap (invalid)>");
}
sf::Vector2i root = self->data->getRoot();
return PyUnicode_FromFormat("<DijkstraMap root=(%d,%d)>", root.x, root.y);
}
PyObject* UIGridPathfinding::DijkstraMap_distance(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"pos", NULL};
PyObject* pos_obj = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast<char**>(kwlist), &pos_obj)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid");
return NULL;
}
int x, y;
if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) {
return NULL;
}
float dist = self->data->getDistance(x, y);
if (dist < 0) {
Py_RETURN_NONE; // Unreachable
}
return PyFloat_FromDouble(dist);
}
PyObject* UIGridPathfinding::DijkstraMap_path_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"pos", NULL};
PyObject* pos_obj = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast<char**>(kwlist), &pos_obj)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid");
return NULL;
}
int x, y;
if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) {
return NULL;
}
std::vector<sf::Vector2i> path = self->data->getPathFrom(x, y);
// Create an AStarPath object to return
PyAStarPathObject* result = (PyAStarPathObject*)mcrfpydef::PyAStarPathType.tp_alloc(
&mcrfpydef::PyAStarPathType, 0);
if (!result) return NULL;
new (&result->path) std::vector<sf::Vector2i>(std::move(path));
result->current_index = 0;
result->origin = sf::Vector2i(x, y);
result->destination = self->data->getRoot();
return (PyObject*)result;
}
PyObject* UIGridPathfinding::DijkstraMap_step_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"pos", NULL};
PyObject* pos_obj = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast<char**>(kwlist), &pos_obj)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid");
return NULL;
}
int x, y;
if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) {
return NULL;
}
bool valid = false;
sf::Vector2i step = self->data->stepFrom(x, y, &valid);
if (!valid) {
Py_RETURN_NONE; // At root or unreachable
}
return PyVector(sf::Vector2f(static_cast<float>(step.x), static_cast<float>(step.y))).pyObject();
}
PyObject* UIGridPathfinding::DijkstraMap_get_root(PyDijkstraMapObject* self, void* closure) {
if (!self->data) {
Py_RETURN_NONE;
}
sf::Vector2i root = self->data->getRoot();
return PyVector(sf::Vector2f(static_cast<float>(root.x), static_cast<float>(root.y))).pyObject();
}
//=============================================================================
// Grid Factory Methods
//=============================================================================
PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL};
PyObject* start_obj = NULL;
PyObject* end_obj = NULL;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast<char**>(kwlist),
&start_obj, &end_obj, &diagonal_cost)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Grid is invalid");
return NULL;
}
int x1, y1, x2, y2;
if (!ExtractPosition(start_obj, &x1, &y1, self->data.get(), "start")) {
return NULL;
}
if (!ExtractPosition(end_obj, &x2, &y2, self->data.get(), "end")) {
return NULL;
}
// Bounds check
if (x1 < 0 || x1 >= self->data->grid_w || y1 < 0 || y1 >= self->data->grid_h ||
x2 < 0 || x2 >= self->data->grid_w || y2 < 0 || y2 >= self->data->grid_h) {
PyErr_SetString(PyExc_ValueError, "Position out of grid bounds");
return NULL;
}
// Compute path using temporary TCODPath
TCODPath tcod_path(self->data->getTCODMap(), diagonal_cost);
if (!tcod_path.compute(x1, y1, x2, y2)) {
Py_RETURN_NONE; // No path exists
}
// Create AStarPath result object
PyAStarPathObject* result = (PyAStarPathObject*)mcrfpydef::PyAStarPathType.tp_alloc(
&mcrfpydef::PyAStarPathType, 0);
if (!result) return NULL;
// Initialize
new (&result->path) std::vector<sf::Vector2i>();
result->current_index = 0;
result->origin = sf::Vector2i(x1, y1);
result->destination = sf::Vector2i(x2, y2);
// Copy path data
result->path.reserve(tcod_path.size());
for (int i = 0; i < tcod_path.size(); i++) {
int px, py;
tcod_path.get(i, &px, &py);
result->path.push_back(sf::Vector2i(px, py));
}
return (PyObject*)result;
}
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"root", "diagonal_cost", NULL};
PyObject* root_obj = NULL;
float diagonal_cost = 1.41f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(kwlist),
&root_obj, &diagonal_cost)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Grid is invalid");
return NULL;
}
int root_x, root_y;
if (!ExtractPosition(root_obj, &root_x, &root_y, self->data.get(), "root")) {
return NULL;
}
// Bounds check
if (root_x < 0 || root_x >= self->data->grid_w || root_y < 0 || root_y >= self->data->grid_h) {
PyErr_SetString(PyExc_ValueError, "Root position out of grid bounds");
return NULL;
}
auto key = std::make_pair(root_x, root_y);
// Check cache
auto it = self->data->dijkstra_maps.find(key);
if (it != self->data->dijkstra_maps.end()) {
// Check diagonal cost matches (or we could ignore this)
if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) {
// Return existing
PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc(
&mcrfpydef::PyDijkstraMapType, 0);
if (!result) return NULL;
new (&result->data) std::shared_ptr<DijkstraMap>(it->second);
return (PyObject*)result;
}
// Different diagonal cost - remove old one
self->data->dijkstra_maps.erase(it);
}
// Create new DijkstraMap
auto dijkstra = std::make_shared<DijkstraMap>(
self->data->getTCODMap(), root_x, root_y, diagonal_cost);
// Cache it
self->data->dijkstra_maps[key] = dijkstra;
// Return Python wrapper
PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc(
&mcrfpydef::PyDijkstraMapType, 0);
if (!result) return NULL;
new (&result->data) std::shared_ptr<DijkstraMap>(dijkstra);
return (PyObject*)result;
}
PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Grid is invalid");
return NULL;
}
self->data->dijkstra_maps.clear();
Py_RETURN_NONE;
}
//=============================================================================
// Python Type Definitions
//=============================================================================
namespace mcrfpydef {
// AStarPath methods
PyMethodDef PyAStarPath_methods[] = {
{"walk", (PyCFunction)UIGridPathfinding::AStarPath_walk, METH_NOARGS,
"walk() -> Vector\n\n"
"Get and consume next step in the path.\n\n"
"Returns:\n"
" Next position as Vector.\n\n"
"Raises:\n"
" IndexError: If path is exhausted."},
{"peek", (PyCFunction)UIGridPathfinding::AStarPath_peek, METH_NOARGS,
"peek() -> Vector\n\n"
"See next step without consuming it.\n\n"
"Returns:\n"
" Next position as Vector.\n\n"
"Raises:\n"
" IndexError: If path is exhausted."},
{NULL}
};
// AStarPath getsetters
PyGetSetDef PyAStarPath_getsetters[] = {
{"origin", (getter)UIGridPathfinding::AStarPath_get_origin, NULL,
"Starting position of the path (Vector, read-only).", NULL},
{"destination", (getter)UIGridPathfinding::AStarPath_get_destination, NULL,
"Ending position of the path (Vector, read-only).", NULL},
{"remaining", (getter)UIGridPathfinding::AStarPath_get_remaining, NULL,
"Number of steps remaining in the path (int, read-only).", NULL},
{NULL}
};
// AStarPath number methods (for bool)
PyNumberMethods PyAStarPath_as_number = {
.nb_bool = UIGridPathfinding::AStarPath_bool,
};
// AStarPath sequence methods (for len)
PySequenceMethods PyAStarPath_as_sequence = {
.sq_length = (lenfunc)UIGridPathfinding::AStarPath_len,
};
// AStarPath type
PyTypeObject PyAStarPathType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.AStarPath",
.tp_basicsize = sizeof(PyAStarPathObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)UIGridPathfinding::AStarPath_dealloc,
.tp_repr = (reprfunc)UIGridPathfinding::AStarPath_repr,
.tp_as_number = &PyAStarPath_as_number,
.tp_as_sequence = &PyAStarPath_as_sequence,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"A computed A* path result, consumed step by step.\n\n"
"Created by Grid.find_path(). Cannot be instantiated directly.\n\n"
"Use walk() to get and consume each step, or iterate directly.\n"
"Use peek() to see the next step without consuming it.\n"
"Use bool(path) or len(path) to check if steps remain.\n\n"
"Properties:\n"
" origin (Vector): Starting position (read-only)\n"
" destination (Vector): Ending position (read-only)\n"
" remaining (int): Steps remaining (read-only)\n\n"
"Example:\n"
" path = grid.find_path(start, end)\n"
" if path:\n"
" while path:\n"
" next_pos = path.walk()\n"
" entity.pos = next_pos"),
.tp_iter = (getiterfunc)UIGridPathfinding::AStarPath_iter,
.tp_methods = PyAStarPath_methods,
.tp_getset = PyAStarPath_getsetters,
.tp_init = (initproc)UIGridPathfinding::AStarPath_init,
.tp_new = UIGridPathfinding::AStarPath_new,
};
// AStarPath iterator type
PyTypeObject PyAStarPathIterType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.AStarPathIterator",
.tp_basicsize = sizeof(PyAStarPathIterObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)AStarPathIter_dealloc,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_iter = (getiterfunc)AStarPathIter_iter,
.tp_iternext = (iternextfunc)AStarPathIter_next,
};
// DijkstraMap methods
PyMethodDef PyDijkstraMap_methods[] = {
{"distance", (PyCFunction)UIGridPathfinding::DijkstraMap_distance, METH_VARARGS | METH_KEYWORDS,
"distance(pos) -> float | None\n\n"
"Get distance from position to root.\n\n"
"Args:\n"
" pos: Position as Vector, Entity, or (x, y) tuple.\n\n"
"Returns:\n"
" Float distance, or None if position is unreachable."},
{"path_from", (PyCFunction)UIGridPathfinding::DijkstraMap_path_from, METH_VARARGS | METH_KEYWORDS,
"path_from(pos) -> AStarPath\n\n"
"Get full path from position to root.\n\n"
"Args:\n"
" pos: Starting position as Vector, Entity, or (x, y) tuple.\n\n"
"Returns:\n"
" AStarPath from pos toward root."},
{"step_from", (PyCFunction)UIGridPathfinding::DijkstraMap_step_from, METH_VARARGS | METH_KEYWORDS,
"step_from(pos) -> Vector | None\n\n"
"Get single step from position toward root.\n\n"
"Args:\n"
" pos: Current position as Vector, Entity, or (x, y) tuple.\n\n"
"Returns:\n"
" Next position as Vector, or None if at root or unreachable."},
{NULL}
};
// DijkstraMap getsetters
PyGetSetDef PyDijkstraMap_getsetters[] = {
{"root", (getter)UIGridPathfinding::DijkstraMap_get_root, NULL,
"Root position that distances are measured from (Vector, read-only).", NULL},
{NULL}
};
// DijkstraMap type
PyTypeObject PyDijkstraMapType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.DijkstraMap",
.tp_basicsize = sizeof(PyDijkstraMapObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)UIGridPathfinding::DijkstraMap_dealloc,
.tp_repr = (reprfunc)UIGridPathfinding::DijkstraMap_repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"A Dijkstra distance map from a fixed root position.\n\n"
"Created by Grid.get_dijkstra_map(). Cannot be instantiated directly.\n\n"
"Grid caches these maps - multiple requests for the same root return\n"
"the same map. Call Grid.clear_dijkstra_maps() after changing grid\n"
"walkability to invalidate the cache.\n\n"
"Properties:\n"
" root (Vector): Root position (read-only)\n\n"
"Methods:\n"
" distance(pos) -> float | None: Get distance to root\n"
" path_from(pos) -> AStarPath: Get full path to root\n"
" step_from(pos) -> Vector | None: Get single step toward root\n\n"
"Example:\n"
" dijkstra = grid.get_dijkstra_map(player.pos)\n"
" for enemy in enemies:\n"
" dist = dijkstra.distance(enemy.pos)\n"
" if dist and dist < 10:\n"
" step = dijkstra.step_from(enemy.pos)\n"
" if step:\n"
" enemy.pos = step"),
.tp_methods = PyDijkstraMap_methods,
.tp_getset = PyDijkstraMap_getsetters,
.tp_init = (initproc)UIGridPathfinding::DijkstraMap_init,
.tp_new = UIGridPathfinding::DijkstraMap_new,
};
} // namespace mcrfpydef

152
src/UIGridPathfinding.h Normal file
View file

@ -0,0 +1,152 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "UIBase.h" // For PyUIGridObject typedef
#include <libtcod.h>
#include <SFML/System/Vector2.hpp>
#include <vector>
#include <memory>
#include <map>
// Forward declarations
class UIGrid;
//=============================================================================
// AStarPath - A computed A* path result, consumed like an iterator
//=============================================================================
struct PyAStarPathObject {
PyObject_HEAD
std::vector<sf::Vector2i> path; // Pre-computed path positions
size_t current_index; // Next step to return
sf::Vector2i origin; // Fixed at creation
sf::Vector2i destination; // Fixed at creation
};
//=============================================================================
// DijkstraMap - A Dijkstra distance field from a fixed root
//=============================================================================
class DijkstraMap {
public:
DijkstraMap(TCODMap* map, int root_x, int root_y, float diagonal_cost);
~DijkstraMap();
// Non-copyable (owns TCODDijkstra)
DijkstraMap(const DijkstraMap&) = delete;
DijkstraMap& operator=(const DijkstraMap&) = delete;
// Queries
float getDistance(int x, int y) const;
std::vector<sf::Vector2i> getPathFrom(int x, int y) const;
sf::Vector2i stepFrom(int x, int y, bool* valid = nullptr) const;
// Accessors
sf::Vector2i getRoot() const { return root; }
float getDiagonalCost() const { return diagonal_cost; }
private:
TCODDijkstra* tcod_dijkstra; // Owned by this object
TCODMap* tcod_map; // Borrowed from Grid
sf::Vector2i root;
float diagonal_cost;
};
struct PyDijkstraMapObject {
PyObject_HEAD
std::shared_ptr<DijkstraMap> data; // Shared with Grid's collection
};
//=============================================================================
// Helper Functions
//=============================================================================
namespace UIGridPathfinding {
// Extract grid position from Vector, Entity, or tuple
// Sets Python error and returns false on failure
// If expected_grid is provided and obj is Entity, validates grid membership
bool ExtractPosition(PyObject* obj, int* x, int* y,
UIGrid* expected_grid = nullptr,
const char* arg_name = "position");
//=========================================================================
// AStarPath Python Type Methods
//=========================================================================
PyObject* AStarPath_new(PyTypeObject* type, PyObject* args, PyObject* kwds);
int AStarPath_init(PyAStarPathObject* self, PyObject* args, PyObject* kwds);
void AStarPath_dealloc(PyAStarPathObject* self);
PyObject* AStarPath_repr(PyAStarPathObject* self);
// Methods
PyObject* AStarPath_walk(PyAStarPathObject* self, PyObject* args);
PyObject* AStarPath_peek(PyAStarPathObject* self, PyObject* args);
// Properties
PyObject* AStarPath_get_origin(PyAStarPathObject* self, void* closure);
PyObject* AStarPath_get_destination(PyAStarPathObject* self, void* closure);
PyObject* AStarPath_get_remaining(PyAStarPathObject* self, void* closure);
// Sequence protocol
Py_ssize_t AStarPath_len(PyAStarPathObject* self);
int AStarPath_bool(PyObject* self);
PyObject* AStarPath_iter(PyAStarPathObject* self);
PyObject* AStarPath_iternext(PyAStarPathObject* self);
//=========================================================================
// DijkstraMap Python Type Methods
//=========================================================================
PyObject* DijkstraMap_new(PyTypeObject* type, PyObject* args, PyObject* kwds);
int DijkstraMap_init(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds);
void DijkstraMap_dealloc(PyDijkstraMapObject* self);
PyObject* DijkstraMap_repr(PyDijkstraMapObject* self);
// Methods
PyObject* DijkstraMap_distance(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds);
PyObject* DijkstraMap_path_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds);
PyObject* DijkstraMap_step_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds);
// Properties
PyObject* DijkstraMap_get_root(PyDijkstraMapObject* self, void* closure);
//=========================================================================
// Grid Factory Methods (called from UIGrid Python bindings)
//=========================================================================
// Grid.find_path() -> AStarPath | None
PyObject* Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
// Grid.get_dijkstra_map() -> DijkstraMap
PyObject* Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds);
// Grid.clear_dijkstra_maps() -> None
PyObject* Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args);
}
//=============================================================================
// Python Type Definitions
//=============================================================================
namespace mcrfpydef {
// AStarPath iterator type
struct PyAStarPathIterObject {
PyObject_HEAD
PyAStarPathObject* path; // Reference to path being iterated
size_t iter_index; // Current iteration position
};
extern PyNumberMethods PyAStarPath_as_number;
extern PySequenceMethods PyAStarPath_as_sequence;
extern PyMethodDef PyAStarPath_methods[];
extern PyGetSetDef PyAStarPath_getsetters[];
extern PyTypeObject PyAStarPathType;
extern PyTypeObject PyAStarPathIterType;
extern PyMethodDef PyDijkstraMap_methods[];
extern PyGetSetDef PyDijkstraMap_getsetters[];
extern PyTypeObject PyDijkstraMapType;
}

View file

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Unit tests for mcrfpy.HeightMap core functionality (#193)
Tests the HeightMap class constructor, size property, and scalar operations.
"""
import sys
import mcrfpy
def test_constructor_basic():
"""HeightMap can be created with a size tuple"""
hmap = mcrfpy.HeightMap((100, 50))
assert hmap is not None
print("PASS: test_constructor_basic")
def test_constructor_with_fill():
"""HeightMap can be created with a fill value"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
assert hmap is not None
print("PASS: test_constructor_with_fill")
def test_size_property():
"""size property returns correct dimensions"""
hmap = mcrfpy.HeightMap((100, 50))
size = hmap.size
assert size == (100, 50), f"Expected (100, 50), got {size}"
print("PASS: test_size_property")
def test_size_immutable():
"""size property is read-only"""
hmap = mcrfpy.HeightMap((100, 50))
try:
hmap.size = (200, 100)
print("FAIL: test_size_immutable - should have raised AttributeError")
sys.exit(1)
except AttributeError:
pass
print("PASS: test_size_immutable")
def test_fill_method():
"""fill() sets all cells and returns self"""
hmap = mcrfpy.HeightMap((10, 10))
result = hmap.fill(0.5)
assert result is hmap, "fill() should return self"
print("PASS: test_fill_method")
def test_clear_method():
"""clear() sets all cells to 0.0 and returns self"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = hmap.clear()
assert result is hmap, "clear() should return self"
print("PASS: test_clear_method")
def test_add_constant_method():
"""add_constant() adds to all cells and returns self"""
hmap = mcrfpy.HeightMap((10, 10))
result = hmap.add_constant(0.25)
assert result is hmap, "add_constant() should return self"
print("PASS: test_add_constant_method")
def test_scale_method():
"""scale() multiplies all cells and returns self"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = hmap.scale(2.0)
assert result is hmap, "scale() should return self"
print("PASS: test_scale_method")
def test_clamp_method():
"""clamp() clamps values and returns self"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = hmap.clamp(0.0, 1.0)
assert result is hmap, "clamp() should return self"
print("PASS: test_clamp_method")
def test_clamp_with_defaults():
"""clamp() works with default parameters"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = hmap.clamp() # Uses defaults 0.0, 1.0
assert result is hmap
print("PASS: test_clamp_with_defaults")
def test_normalize_method():
"""normalize() rescales values and returns self"""
hmap = mcrfpy.HeightMap((10, 10))
hmap.fill(0.25).add_constant(0.1) # Some values
result = hmap.normalize(0.0, 1.0)
assert result is hmap, "normalize() should return self"
print("PASS: test_normalize_method")
def test_normalize_with_defaults():
"""normalize() works with default parameters"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = hmap.normalize() # Uses defaults 0.0, 1.0
assert result is hmap
print("PASS: test_normalize_with_defaults")
def test_method_chaining():
"""Methods can be chained"""
hmap = mcrfpy.HeightMap((10, 10))
result = hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0)
assert result is hmap, "Chained methods should return self"
print("PASS: test_method_chaining")
def test_complex_chaining():
"""Complex chains work correctly"""
hmap = mcrfpy.HeightMap((100, 100))
result = (hmap
.fill(0.0)
.add_constant(0.5)
.scale(1.5)
.clamp(0.0, 1.0)
.normalize(0.2, 0.8))
assert result is hmap
print("PASS: test_complex_chaining")
def test_repr():
"""repr() returns a readable string"""
hmap = mcrfpy.HeightMap((100, 50))
r = repr(hmap)
assert "HeightMap" in r
assert "100" in r and "50" in r
print(f"PASS: test_repr - {r}")
def test_invalid_size():
"""Negative or zero size raises ValueError"""
try:
mcrfpy.HeightMap((0, 10))
print("FAIL: test_invalid_size - should have raised ValueError for width=0")
sys.exit(1)
except ValueError:
pass
try:
mcrfpy.HeightMap((10, -5))
print("FAIL: test_invalid_size - should have raised ValueError for height=-5")
sys.exit(1)
except ValueError:
pass
print("PASS: test_invalid_size")
def test_invalid_size_type():
"""Non-tuple size raises TypeError"""
try:
mcrfpy.HeightMap([100, 50]) # list instead of tuple
print("FAIL: test_invalid_size_type - should have raised TypeError")
sys.exit(1)
except TypeError:
pass
print("PASS: test_invalid_size_type")
def test_size_exceeds_grid_max():
"""Size exceeding GRID_MAX (8192) raises ValueError"""
# Test width exceeds limit
try:
mcrfpy.HeightMap((10000, 100))
print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for width=10000")
sys.exit(1)
except ValueError as e:
assert "8192" in str(e) or "cannot exceed" in str(e).lower()
# Test height exceeds limit
try:
mcrfpy.HeightMap((100, 10000))
print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for height=10000")
sys.exit(1)
except ValueError as e:
assert "8192" in str(e) or "cannot exceed" in str(e).lower()
# Test both exceed limit (would cause integer overflow without validation)
try:
mcrfpy.HeightMap((65536, 65536))
print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for 65536x65536")
sys.exit(1)
except ValueError:
pass
print("PASS: test_size_exceeds_grid_max")
def test_clamp_min_greater_than_max():
"""clamp() with min > max raises ValueError"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
try:
hmap.clamp(min=1.0, max=0.0)
print("FAIL: test_clamp_min_greater_than_max - should have raised ValueError")
sys.exit(1)
except ValueError as e:
assert "min" in str(e).lower() and "max" in str(e).lower()
print("PASS: test_clamp_min_greater_than_max")
def test_normalize_min_greater_than_max():
"""normalize() with min > max raises ValueError"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
try:
hmap.normalize(min=1.0, max=0.0)
print("FAIL: test_normalize_min_greater_than_max - should have raised ValueError")
sys.exit(1)
except ValueError as e:
assert "min" in str(e).lower() and "max" in str(e).lower()
print("PASS: test_normalize_min_greater_than_max")
def test_max_valid_size():
"""Size at GRID_MAX boundary works"""
# Test at the exact limit - this should work
hmap = mcrfpy.HeightMap((8192, 1))
assert hmap.size == (8192, 1)
hmap2 = mcrfpy.HeightMap((1, 8192))
assert hmap2.size == (1, 8192)
print("PASS: test_max_valid_size")
def run_all_tests():
"""Run all tests"""
print("Running HeightMap basic tests...")
print()
test_constructor_basic()
test_constructor_with_fill()
test_size_property()
test_size_immutable()
test_fill_method()
test_clear_method()
test_add_constant_method()
test_scale_method()
test_clamp_method()
test_clamp_with_defaults()
test_normalize_method()
test_normalize_with_defaults()
test_method_chaining()
test_complex_chaining()
test_repr()
test_invalid_size()
test_invalid_size_type()
test_size_exceeds_grid_max()
test_clamp_min_greater_than_max()
test_normalize_min_greater_than_max()
test_max_valid_size()
print()
print("All HeightMap basic tests PASSED!")
# Run tests directly
run_all_tests()
sys.exit(0)