From 63008bdefdd81d31b20c78b1fab48385ce26f481 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 4 Feb 2026 16:36:21 -0500 Subject: [PATCH] pathfinding on heightmap --- src/3d/Viewport3D.cpp | 555 ++++++++++++++++++++++++++ src/3d/Viewport3D.h | 80 ++++ src/3d/VoxelPoint.cpp | 225 +++++++++++ src/3d/VoxelPoint.h | 94 +++++ src/McRFPy_API.cpp | 3 + tests/demo/screens/navigation_demo.py | 393 ++++++++++++++++++ 6 files changed, 1350 insertions(+) create mode 100644 src/3d/VoxelPoint.cpp create mode 100644 src/3d/VoxelPoint.h create mode 100644 tests/demo/screens/navigation_demo.py diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 0762768..72ab105 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -54,6 +54,10 @@ Viewport3D::Viewport3D(float x, float y, float width, float height) Viewport3D::~Viewport3D() { cleanupTestGeometry(); cleanupFBO(); + if (tcodMap_) { + delete tcodMap_; + tcodMap_ = nullptr; + } } // ============================================================================= @@ -210,6 +214,215 @@ bool Viewport3D::removeLayer(const std::string& name) { return false; } +// ============================================================================= +// Navigation Grid (VoxelPoint System) +// ============================================================================= + +void Viewport3D::setGridSize(int width, int depth) { + if (width <= 0 || depth <= 0) { + throw std::invalid_argument("Grid dimensions must be positive"); + } + + gridWidth_ = width; + gridDepth_ = depth; + + // Resize and initialize grid + navGrid_.resize(width * depth); + for (int z = 0; z < depth; z++) { + for (int x = 0; x < width; x++) { + int idx = z * width + x; + navGrid_[idx] = VoxelPoint(x, z, this); + } + } + + // Create/recreate TCODMap + if (tcodMap_) { + delete tcodMap_; + } + tcodMap_ = new TCODMap(width, depth); + + // Sync initial state + syncToTCOD(); +} + +VoxelPoint& Viewport3D::at(int x, int z) { + if (!isValidCell(x, z)) { + throw std::out_of_range("Grid coordinates out of range"); + } + return navGrid_[z * gridWidth_ + x]; +} + +const VoxelPoint& Viewport3D::at(int x, int z) const { + if (!isValidCell(x, z)) { + throw std::out_of_range("Grid coordinates out of range"); + } + return navGrid_[z * gridWidth_ + x]; +} + +bool Viewport3D::isValidCell(int x, int z) const { + return x >= 0 && x < gridWidth_ && z >= 0 && z < gridDepth_; +} + +void Viewport3D::syncToTCOD() { + if (!tcodMap_) return; + + for (int z = 0; z < gridDepth_; z++) { + for (int x = 0; x < gridWidth_; x++) { + const VoxelPoint& vp = at(x, z); + tcodMap_->setProperties(x, z, vp.transparent, vp.walkable); + } + } +} + +void Viewport3D::syncTCODCell(int x, int z) { + if (!tcodMap_ || !isValidCell(x, z)) return; + + const VoxelPoint& vp = at(x, z); + tcodMap_->setProperties(x, z, vp.transparent, vp.walkable); +} + +void Viewport3D::applyHeightmap(TCOD_heightmap_t* hm, float yScale) { + if (!hm) return; + + // Ensure grid matches heightmap dimensions + if (gridWidth_ != hm->w || gridDepth_ != hm->h) { + setGridSize(hm->w, hm->h); + } + + // Apply heights + for (int z = 0; z < gridDepth_; z++) { + for (int x = 0; x < gridWidth_; x++) { + int idx = z * hm->w + x; + navGrid_[z * gridWidth_ + x].height = hm->values[idx] * yScale; + } + } +} + +void Viewport3D::applyThreshold(TCOD_heightmap_t* hm, float minHeight, float maxHeight, bool walkable) { + if (!hm) return; + + // Grid must match heightmap dimensions + if (gridWidth_ != hm->w || gridDepth_ != hm->h) { + return; // Dimension mismatch + } + + for (int z = 0; z < gridDepth_; z++) { + for (int x = 0; x < gridWidth_; x++) { + int idx = z * hm->w + x; + float h = hm->values[idx]; + if (h >= minHeight && h <= maxHeight) { + navGrid_[z * gridWidth_ + x].walkable = walkable; + } + } + } + + syncToTCOD(); +} + +void Viewport3D::setSlopeCost(float maxSlope, float costMultiplier) { + if (gridWidth_ < 2 || gridDepth_ < 2) return; + + // Neighbor offsets (4-directional) + const int dx[] = {-1, 1, 0, 0}; + const int dz[] = {0, 0, -1, 1}; + + for (int z = 0; z < gridDepth_; z++) { + for (int x = 0; x < gridWidth_; x++) { + VoxelPoint& vp = navGrid_[z * gridWidth_ + x]; + float maxNeighborDiff = 0.0f; + + // Check all neighbors + for (int i = 0; i < 4; i++) { + int nx = x + dx[i]; + int nz = z + dz[i]; + if (isValidCell(nx, nz)) { + float diff = std::abs(vp.height - at(nx, nz).height); + maxNeighborDiff = std::max(maxNeighborDiff, diff); + } + } + + // Mark unwalkable if too steep, otherwise set cost + if (maxNeighborDiff > maxSlope) { + vp.walkable = false; + } else { + vp.cost = 1.0f + maxNeighborDiff * costMultiplier; + } + } + } + + syncToTCOD(); +} + +std::vector> Viewport3D::findPath(int startX, int startZ, int endX, int endZ) { + std::vector> result; + + if (!tcodMap_ || !isValidCell(startX, startZ) || !isValidCell(endX, endZ)) { + return result; + } + + // Ensure TCOD is synced + syncToTCOD(); + + // Create path with cost callback + struct PathUserData { + Viewport3D* viewport; + }; + PathUserData userData = {this}; + + // Use TCODPath with diagonal movement + TCODPath path(tcodMap_, 1.41f); + + // Compute path + if (!path.compute(startX, startZ, endX, endZ)) { + return result; // No path found + } + + // Extract path + int x, z; + while (path.walk(&x, &z, true)) { + result.push_back({x, z}); + } + + return result; +} + +std::vector> Viewport3D::computeFOV(int originX, int originZ, int radius) { + std::vector> visible; + + if (!tcodMap_ || !isValidCell(originX, originZ)) { + return visible; + } + + // Thread-safe FOV computation + std::lock_guard lock(fovMutex_); + + // Ensure TCOD is synced + syncToTCOD(); + + // Compute FOV + tcodMap_->computeFov(originX, originZ, radius, true, FOV_BASIC); + + // Collect visible cells + for (int z = 0; z < gridDepth_; z++) { + for (int x = 0; x < gridWidth_; x++) { + if (tcodMap_->isInFov(x, z)) { + visible.push_back({x, z}); + } + } + } + + return visible; +} + +bool Viewport3D::isInFOV(int x, int z) const { + if (!tcodMap_ || !isValidCell(x, z)) { + return false; + } + + std::lock_guard lock(fovMutex_); + return tcodMap_->isInFov(x, z); +} + // ============================================================================= // FBO Management // ============================================================================= @@ -862,6 +1075,56 @@ static int Viewport3D_set_fog_far(PyViewport3DObject* self, PyObject* value, voi return 0; } +// Navigation grid property getters/setters +static PyObject* Viewport3D_get_grid_size_prop(PyViewport3DObject* self, void* closure) { + return Py_BuildValue("(ii)", self->data->getGridWidth(), self->data->getGridDepth()); +} + +static int Viewport3D_set_grid_size_prop(PyViewport3DObject* self, PyObject* value, void* closure) { + if (!PyTuple_Check(value) || PyTuple_Size(value) != 2) { + PyErr_SetString(PyExc_TypeError, "grid_size must be a tuple of (width, depth)"); + return -1; + } + + int width, depth; + if (!PyArg_ParseTuple(value, "ii", &width, &depth)) { + return -1; + } + + try { + self->data->setGridSize(width, depth); + } catch (const std::exception& e) { + PyErr_SetString(PyExc_ValueError, e.what()); + return -1; + } + + return 0; +} + +static PyObject* Viewport3D_get_cell_size_prop(PyViewport3DObject* self, void* closure) { + return PyFloat_FromDouble(self->data->getCellSize()); +} + +static int Viewport3D_set_cell_size_prop(PyViewport3DObject* self, PyObject* value, void* closure) { + double size; + if (PyFloat_Check(value)) { + size = PyFloat_AsDouble(value); + } else if (PyLong_Check(value)) { + size = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "cell_size must be a number"); + return -1; + } + + if (size <= 0) { + PyErr_SetString(PyExc_ValueError, "cell_size must be positive"); + return -1; + } + + self->data->setCellSize(static_cast(size)); + return 0; +} + PyGetSetDef Viewport3D::getsetters[] = { // Position and size {"x", (getter)Viewport3D_get_x, (setter)Viewport3D_set_x, @@ -909,6 +1172,12 @@ PyGetSetDef Viewport3D::getsetters[] = { {"fog_far", (getter)Viewport3D_get_fog_far, (setter)Viewport3D_set_fog_far, MCRF_PROPERTY(fog_far, "Fog end distance."), NULL}, + // Navigation grid properties + {"grid_size", (getter)Viewport3D_get_grid_size_prop, (setter)Viewport3D_set_grid_size_prop, + MCRF_PROPERTY(grid_size, "Navigation grid dimensions as (width, depth) tuple."), NULL}, + {"cell_size", (getter)Viewport3D_get_cell_size_prop, (setter)Viewport3D_set_cell_size_prop, + MCRF_PROPERTY(cell_size, "World units per navigation grid cell."), NULL}, + // Common UIDrawable properties UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIVIEWPORT3D), @@ -1238,6 +1507,232 @@ static PyObject* Viewport3D_layer_count(PyViewport3DObject* self, PyObject* Py_U return PyLong_FromSize_t(self->data->getLayerCount()); } +// ============================================================================= +// Navigation Grid Python Methods +// ============================================================================= + +static PyObject* Viewport3D_set_grid_size(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"width", "depth", NULL}; + int width = 0; + int depth = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "ii", const_cast(kwlist), + &width, &depth)) { + return NULL; + } + + try { + self->data->setGridSize(width, depth); + } catch (const std::exception& e) { + PyErr_SetString(PyExc_ValueError, e.what()); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_at(PyViewport3DObject* self, PyObject* args) { + int x, z; + + if (!PyArg_ParseTuple(args, "ii", &x, &z)) { + return NULL; + } + + if (!self->data->isValidCell(x, z)) { + PyErr_Format(PyExc_IndexError, "Grid coordinates (%d, %d) out of range", x, z); + return NULL; + } + + // Create Python VoxelPoint wrapper using tp_alloc to properly construct shared_ptr + auto type = &mcrfpydef::PyVoxelPointType; + auto vp_obj = (PyVoxelPointObject*)type->tp_alloc(type, 0); + if (!vp_obj) { + return NULL; + } + + vp_obj->data = &(self->data->at(x, z)); + vp_obj->viewport = self->data; + + return (PyObject*)vp_obj; +} + +static PyObject* Viewport3D_apply_heightmap(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"heightmap", "y_scale", NULL}; + PyObject* hm_obj = nullptr; + float y_scale = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(kwlist), + &hm_obj, &y_scale)) { + return NULL; + } + + // Validate HeightMap type + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found"); + return NULL; + } + + if (!PyObject_IsInstance(hm_obj, heightmap_type)) { + Py_DECREF(heightmap_type); + PyErr_SetString(PyExc_TypeError, "heightmap must be a HeightMap object"); + return NULL; + } + Py_DECREF(heightmap_type); + + PyHeightMapObject* hm = reinterpret_cast(hm_obj); + if (!hm->heightmap) { + PyErr_SetString(PyExc_ValueError, "HeightMap has no data"); + return NULL; + } + + self->data->applyHeightmap(hm->heightmap, y_scale); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_apply_threshold(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"heightmap", "min_height", "max_height", "walkable", NULL}; + PyObject* hm_obj = nullptr; + float min_height = 0.0f; + float max_height = 1.0f; + int walkable = 1; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Off|p", const_cast(kwlist), + &hm_obj, &min_height, &max_height, &walkable)) { + return NULL; + } + + // Validate HeightMap type + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found"); + return NULL; + } + + if (!PyObject_IsInstance(hm_obj, heightmap_type)) { + Py_DECREF(heightmap_type); + PyErr_SetString(PyExc_TypeError, "heightmap must be a HeightMap object"); + return NULL; + } + Py_DECREF(heightmap_type); + + PyHeightMapObject* hm = reinterpret_cast(hm_obj); + if (!hm->heightmap) { + PyErr_SetString(PyExc_ValueError, "HeightMap has no data"); + return NULL; + } + + self->data->applyThreshold(hm->heightmap, min_height, max_height, walkable != 0); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_set_slope_cost(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"max_slope", "cost_multiplier", NULL}; + float max_slope = 0.5f; + float cost_multiplier = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(kwlist), + &max_slope, &cost_multiplier)) { + return NULL; + } + + self->data->setSlopeCost(max_slope, cost_multiplier); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_find_path(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"start", "end", NULL}; + PyObject* start_obj = nullptr; + PyObject* end_obj = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO", const_cast(kwlist), + &start_obj, &end_obj)) { + return NULL; + } + + // Parse start tuple + int start_x, start_z; + if (!PyArg_ParseTuple(start_obj, "ii", &start_x, &start_z)) { + PyErr_SetString(PyExc_TypeError, "start must be a tuple of (x, z) integers"); + return NULL; + } + + // Parse end tuple + int end_x, end_z; + if (!PyArg_ParseTuple(end_obj, "ii", &end_x, &end_z)) { + PyErr_SetString(PyExc_TypeError, "end must be a tuple of (x, z) integers"); + return NULL; + } + + // Find path + std::vector> path = self->data->findPath(start_x, start_z, end_x, end_z); + + // Convert to Python list + PyObject* result = PyList_New(path.size()); + if (!result) { + return NULL; + } + + for (size_t i = 0; i < path.size(); i++) { + PyObject* tuple = Py_BuildValue("(ii)", path[i].first, path[i].second); + if (!tuple) { + Py_DECREF(result); + return NULL; + } + PyList_SET_ITEM(result, i, tuple); + } + + return result; +} + +static PyObject* Viewport3D_compute_fov(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"origin", "radius", NULL}; + PyObject* origin_obj = nullptr; + int radius = 10; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast(kwlist), + &origin_obj, &radius)) { + return NULL; + } + + // Parse origin tuple + int origin_x, origin_z; + if (!PyArg_ParseTuple(origin_obj, "ii", &origin_x, &origin_z)) { + PyErr_SetString(PyExc_TypeError, "origin must be a tuple of (x, z) integers"); + return NULL; + } + + // Compute FOV + std::vector> visible = self->data->computeFOV(origin_x, origin_z, radius); + + // Convert to Python list + PyObject* result = PyList_New(visible.size()); + if (!result) { + return NULL; + } + + for (size_t i = 0; i < visible.size(); i++) { + PyObject* tuple = Py_BuildValue("(ii)", visible[i].first, visible[i].second); + if (!tuple) { + Py_DECREF(result); + return NULL; + } + PyList_SET_ITEM(result, i, tuple); + } + + return result; +} + +static PyObject* Viewport3D_is_in_fov(PyViewport3DObject* self, PyObject* args) { + int x, z; + + if (!PyArg_ParseTuple(args, "ii", &x, &z)) { + return NULL; + } + + return PyBool_FromLong(self->data->isInFOV(x, z)); +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -1286,5 +1781,65 @@ PyMethodDef Viewport3D_methods[] = { {"layer_count", (PyCFunction)mcrf::Viewport3D_layer_count, METH_NOARGS, "layer_count() -> int\n\n" "Get the number of mesh layers."}, + + // Navigation grid methods + {"set_grid_size", (PyCFunction)mcrf::Viewport3D_set_grid_size, METH_VARARGS | METH_KEYWORDS, + "set_grid_size(width, depth)\n\n" + "Initialize navigation grid with specified dimensions.\n\n" + "Args:\n" + " width: Grid width (X axis)\n" + " depth: Grid depth (Z axis)"}, + {"at", (PyCFunction)mcrf::Viewport3D_at, METH_VARARGS, + "at(x, z) -> VoxelPoint\n\n" + "Get VoxelPoint at grid coordinates.\n\n" + "Args:\n" + " x: X coordinate in grid\n" + " z: Z coordinate in grid\n\n" + "Returns:\n" + " VoxelPoint object for the cell"}, + {"apply_heightmap", (PyCFunction)mcrf::Viewport3D_apply_heightmap, METH_VARARGS | METH_KEYWORDS, + "apply_heightmap(heightmap, y_scale=1.0)\n\n" + "Set cell heights from HeightMap.\n\n" + "Args:\n" + " heightmap: HeightMap object\n" + " y_scale: Vertical scale factor"}, + {"apply_threshold", (PyCFunction)mcrf::Viewport3D_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(heightmap, min_height, max_height, walkable=True)\n\n" + "Set cell walkability based on height thresholds.\n\n" + "Args:\n" + " heightmap: HeightMap object\n" + " min_height: Minimum height (0-1)\n" + " max_height: Maximum height (0-1)\n" + " walkable: Walkability value for cells in range"}, + {"set_slope_cost", (PyCFunction)mcrf::Viewport3D_set_slope_cost, METH_VARARGS | METH_KEYWORDS, + "set_slope_cost(max_slope=0.5, cost_multiplier=1.0)\n\n" + "Calculate slope costs and mark steep cells unwalkable.\n\n" + "Args:\n" + " max_slope: Maximum height difference before marking unwalkable\n" + " cost_multiplier: Cost increase per unit slope"}, + {"find_path", (PyCFunction)mcrf::Viewport3D_find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(start, end) -> list\n\n" + "Find A* path between two points.\n\n" + "Args:\n" + " start: Starting point as (x, z) tuple\n" + " end: End point as (x, z) tuple\n\n" + "Returns:\n" + " List of (x, z) tuples forming the path, or empty list if no path"}, + {"compute_fov", (PyCFunction)mcrf::Viewport3D_compute_fov, METH_VARARGS | METH_KEYWORDS, + "compute_fov(origin, radius=10) -> list\n\n" + "Compute field of view from a position.\n\n" + "Args:\n" + " origin: Origin point as (x, z) tuple\n" + " radius: FOV radius\n\n" + "Returns:\n" + " List of visible (x, z) positions"}, + {"is_in_fov", (PyCFunction)mcrf::Viewport3D_is_in_fov, METH_VARARGS, + "is_in_fov(x, z) -> bool\n\n" + "Check if a cell is in the current FOV (after compute_fov).\n\n" + "Args:\n" + " x: X coordinate\n" + " z: Z coordinate\n\n" + "Returns:\n" + " True if the cell is visible"}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index ab6b5b9..273557e 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -11,9 +11,12 @@ #include "PyDrawable.h" #include "Math3D.h" #include "Camera3D.h" +#include "VoxelPoint.h" #include #include #include +#include +#include namespace mcrf { @@ -99,6 +102,75 @@ public: /// Get number of layers size_t getLayerCount() const { return meshLayers_.size(); } + // ========================================================================= + // Navigation Grid (VoxelPoint System) + // ========================================================================= + + /// Set navigation grid dimensions + /// @param width Grid width (X axis) + /// @param depth Grid depth (Z axis) + void setGridSize(int width, int depth); + + /// Get grid dimensions + int getGridWidth() const { return gridWidth_; } + int getGridDepth() const { return gridDepth_; } + + /// Access a VoxelPoint at grid coordinates + /// @throws std::out_of_range if coordinates are invalid + VoxelPoint& at(int x, int z); + const VoxelPoint& at(int x, int z) const; + + /// Check if coordinates are within grid bounds + bool isValidCell(int x, int z) const; + + /// Set cell size (world units per grid cell) + void setCellSize(float size) { cellSize_ = size; } + float getCellSize() const { return cellSize_; } + + /// Synchronize all cells to libtcod TCODMap + void syncToTCOD(); + + /// Synchronize a single cell to TCODMap + void syncTCODCell(int x, int z); + + /// Apply heights from HeightMap to navigation grid + /// @param hm HeightMap to read heights from + /// @param yScale Scale factor for Y values + void applyHeightmap(TCOD_heightmap_t* hm, float yScale); + + /// Set cell walkability by height threshold + /// @param hm HeightMap to sample + /// @param minHeight Minimum height for threshold + /// @param maxHeight Maximum height for threshold + /// @param walkable Walkability value to set for cells in range + void applyThreshold(TCOD_heightmap_t* hm, float minHeight, float maxHeight, bool walkable); + + /// Calculate slope costs and mark steep cells unwalkable + /// @param maxSlope Maximum height difference before marking unwalkable + /// @param costMultiplier Cost increase per unit slope + void setSlopeCost(float maxSlope, float costMultiplier); + + /// Find path using A* pathfinding + /// @param startX Start X coordinate + /// @param startZ Start Z coordinate + /// @param endX End X coordinate + /// @param endZ End Z coordinate + /// @return Vector of (x, z) positions, or empty if no path + std::vector> findPath(int startX, int startZ, int endX, int endZ); + + /// Compute field of view from a position + /// @param originX Origin X coordinate + /// @param originZ Origin Z coordinate + /// @param radius FOV radius + /// @return Set of visible (x, z) positions + std::vector> computeFOV(int originX, int originZ, int radius); + + /// Check if a cell is in current FOV (after computeFOV call) + bool isInFOV(int x, int z) const; + + /// Get TCODMap pointer (for advanced usage) + TCODMap* getTCODMap() const { return tcodMap_; } + // Background color void setBackgroundColor(const sf::Color& color) { bgColor_ = color; } sf::Color getBackgroundColor() const { return bgColor_; } @@ -174,6 +246,14 @@ private: // Mesh layers for terrain, static geometry std::vector> meshLayers_; + // Navigation grid (VoxelPoint system) + std::vector navGrid_; + int gridWidth_ = 0; + int gridDepth_ = 0; + float cellSize_ = 1.0f; + TCODMap* tcodMap_ = nullptr; + mutable std::mutex fovMutex_; + // Shader for PS1-style rendering std::unique_ptr shader_; diff --git a/src/3d/VoxelPoint.cpp b/src/3d/VoxelPoint.cpp new file mode 100644 index 0000000..b6d414c --- /dev/null +++ b/src/3d/VoxelPoint.cpp @@ -0,0 +1,225 @@ +// VoxelPoint.cpp - Navigation grid cell implementation + +#include "VoxelPoint.h" +#include "Viewport3D.h" +#include + +// Default constructor +VoxelPoint::VoxelPoint() + : walkable(true) + , transparent(true) + , height(0.0f) + , cost(1.0f) + , grid_x(0) + , grid_z(0) + , parent_viewport(nullptr) +{ +} + +// Constructor with position +VoxelPoint::VoxelPoint(int x, int z, mcrf::Viewport3D* parent) + : walkable(true) + , transparent(true) + , height(0.0f) + , cost(1.0f) + , grid_x(x) + , grid_z(z) + , parent_viewport(parent) +{ +} + +// ============================================================================= +// Python Property Accessors +// ============================================================================= + +// Member offsets for bool properties +enum VoxelPointBoolMember { + VOXEL_WALKABLE = 0, + VOXEL_TRANSPARENT = 1 +}; + +// Member offsets for float properties +enum VoxelPointFloatMember { + VOXEL_HEIGHT = 0, + VOXEL_COST = 1 +}; + +PyObject* VoxelPoint::get_bool_member(PyVoxelPointObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelPoint data is null"); + return NULL; + } + + long member = reinterpret_cast(closure); + bool value = false; + + switch (member) { + case VOXEL_WALKABLE: + value = self->data->walkable; + break; + case VOXEL_TRANSPARENT: + value = self->data->transparent; + break; + default: + PyErr_SetString(PyExc_AttributeError, "Invalid bool member"); + return NULL; + } + + return PyBool_FromLong(value ? 1 : 0); +} + +int VoxelPoint::set_bool_member(PyVoxelPointObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelPoint data is null"); + return -1; + } + + if (!PyBool_Check(value) && !PyLong_Check(value)) { + PyErr_SetString(PyExc_TypeError, "Value must be a boolean"); + return -1; + } + + bool newValue = PyObject_IsTrue(value); + long member = reinterpret_cast(closure); + + switch (member) { + case VOXEL_WALKABLE: + self->data->walkable = newValue; + break; + case VOXEL_TRANSPARENT: + self->data->transparent = newValue; + break; + default: + PyErr_SetString(PyExc_AttributeError, "Invalid bool member"); + return -1; + } + + // Trigger TCOD synchronization + if (self->data->parent_viewport) { + self->data->parent_viewport->syncTCODCell(self->data->grid_x, self->data->grid_z); + } + + return 0; +} + +PyObject* VoxelPoint::get_float_member(PyVoxelPointObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelPoint data is null"); + return NULL; + } + + long member = reinterpret_cast(closure); + float value = 0.0f; + + switch (member) { + case VOXEL_HEIGHT: + value = self->data->height; + break; + case VOXEL_COST: + value = self->data->cost; + break; + default: + PyErr_SetString(PyExc_AttributeError, "Invalid float member"); + return NULL; + } + + return PyFloat_FromDouble(value); +} + +int VoxelPoint::set_float_member(PyVoxelPointObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelPoint data is null"); + return -1; + } + + double newValue; + if (PyFloat_Check(value)) { + newValue = PyFloat_AsDouble(value); + } else if (PyLong_Check(value)) { + newValue = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "Value must be a number"); + return -1; + } + + long member = reinterpret_cast(closure); + + switch (member) { + case VOXEL_HEIGHT: + self->data->height = static_cast(newValue); + break; + case VOXEL_COST: + if (newValue < 0.0) { + PyErr_SetString(PyExc_ValueError, "Cost must be non-negative"); + return -1; + } + self->data->cost = static_cast(newValue); + break; + default: + PyErr_SetString(PyExc_AttributeError, "Invalid float member"); + return -1; + } + + return 0; +} + +PyObject* VoxelPoint::get_grid_pos(PyVoxelPointObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelPoint data is null"); + return NULL; + } + + return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_z); +} + +PyObject* VoxelPoint::get_entities(PyVoxelPointObject* self, void* closure) +{ + // TODO: Implement when Entity3D is created + // For now, return an empty list + return PyList_New(0); +} + +PyObject* VoxelPoint::repr(PyVoxelPointObject* self) +{ + if (!self->data) { + return PyUnicode_FromString(""); + } + + // Use snprintf then PyUnicode_FromString because PyUnicode_FromFormat doesn't support %.2f + char buffer[128]; + snprintf(buffer, sizeof(buffer), + "", + self->data->grid_x, + self->data->grid_z, + self->data->walkable ? "True" : "False", + self->data->transparent ? "True" : "False", + self->data->height, + self->data->cost + ); + return PyUnicode_FromString(buffer); +} + +// ============================================================================= +// Python GetSetDef Table +// ============================================================================= + +PyGetSetDef VoxelPoint::getsetters[] = { + {"walkable", (getter)VoxelPoint::get_bool_member, (setter)VoxelPoint::set_bool_member, + "Whether entities can walk through this cell.", (void*)VOXEL_WALKABLE}, + {"transparent", (getter)VoxelPoint::get_bool_member, (setter)VoxelPoint::set_bool_member, + "Whether FOV can see through this cell.", (void*)VOXEL_TRANSPARENT}, + {"height", (getter)VoxelPoint::get_float_member, (setter)VoxelPoint::set_float_member, + "World-space Y coordinate from terrain.", (void*)VOXEL_HEIGHT}, + {"cost", (getter)VoxelPoint::get_float_member, (setter)VoxelPoint::set_float_member, + "Movement cost multiplier (1.0 = normal).", (void*)VOXEL_COST}, + {"grid_pos", (getter)VoxelPoint::get_grid_pos, NULL, + "Grid coordinates as (x, z) tuple (read-only).", NULL}, + {"entities", (getter)VoxelPoint::get_entities, NULL, + "List of Entity3D objects at this cell (read-only).", NULL}, + {NULL} // Sentinel +}; diff --git a/src/3d/VoxelPoint.h b/src/3d/VoxelPoint.h new file mode 100644 index 0000000..534c60e --- /dev/null +++ b/src/3d/VoxelPoint.h @@ -0,0 +1,94 @@ +// VoxelPoint.h - Navigation grid cell for 3D terrain +// Provides walkability, transparency, and cost data for pathfinding and FOV + +#pragma once + +#include "Common.h" +#include "Python.h" +#include "structmember.h" +#include + +// Forward declarations +namespace mcrf { +class Viewport3D; +class Entity3D; +} + +class VoxelPoint; + +// Python object struct for VoxelPoint +typedef struct { + PyObject_HEAD + VoxelPoint* data; + std::shared_ptr viewport; +} PyVoxelPointObject; + +// VoxelPoint - navigation grid cell for 3D terrain +// Similar to UIGridPoint but with height and cost for 3D pathfinding +class VoxelPoint { +public: + // Navigation properties + bool walkable = true; // Can entities walk through this cell? + bool transparent = true; // Can FOV see through this cell? + float height = 0.0f; // World-space Y coordinate from terrain + float cost = 1.0f; // Movement cost multiplier (1.0 = normal) + + // Position in parent grid + int grid_x = 0; + int grid_z = 0; + + // Parent viewport reference for TCOD synchronization + mcrf::Viewport3D* parent_viewport = nullptr; + + // Default constructor + VoxelPoint(); + + // Constructor with position + VoxelPoint(int x, int z, mcrf::Viewport3D* parent); + + // Python property accessors + static PyGetSetDef getsetters[]; + + // Bool property getter/setter (walkable, transparent) + static PyObject* get_bool_member(PyVoxelPointObject* self, void* closure); + static int set_bool_member(PyVoxelPointObject* self, PyObject* value, void* closure); + + // Float property getter/setter (height, cost) + static PyObject* get_float_member(PyVoxelPointObject* self, void* closure); + static int set_float_member(PyVoxelPointObject* self, PyObject* value, void* closure); + + // grid_pos property (read-only tuple) + static PyObject* get_grid_pos(PyVoxelPointObject* self, void* closure); + + // entities property (read-only list of Entity3D at this cell) + static PyObject* get_entities(PyVoxelPointObject* self, void* closure); + + // __repr__ + static PyObject* repr(PyVoxelPointObject* self); +}; + +namespace mcrfpydef { + +// Python type definition for VoxelPoint +inline PyTypeObject PyVoxelPointType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.VoxelPoint", + .tp_basicsize = sizeof(PyVoxelPointObject), + .tp_itemsize = 0, + .tp_repr = (reprfunc)VoxelPoint::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("VoxelPoint - Navigation grid cell for 3D terrain.\n\n" + "VoxelPoints are accessed via Viewport3D.at(x, z) and cannot be\n" + "instantiated directly.\n\n" + "Properties:\n" + " walkable (bool): Can entities walk through this cell?\n" + " transparent (bool): Can FOV see through this cell?\n" + " height (float): World-space Y coordinate from terrain.\n" + " cost (float): Movement cost multiplier (1.0 = normal).\n" + " grid_pos (tuple, read-only): (x, z) position in grid.\n" + " entities (list, read-only): Entity3D objects at this cell."), + .tp_getset = VoxelPoint::getsetters, + .tp_new = NULL, // Cannot instantiate from Python - access via viewport.at() +}; + +} // namespace mcrfpydef diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 8baf273..1a25f19 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -483,6 +483,9 @@ PyObject* PyInit_mcrfpy() /*game map & perspective data - returned by Grid.at() but not directly instantiable*/ &PyUIGridPointType, &PyUIGridPointStateType, + /*3D navigation grid - returned by Viewport3D.at() but not directly instantiable*/ + &mcrfpydef::PyVoxelPointType, + /*collections & iterators - returned by .children/.entities but not directly instantiable*/ &PyUICollectionType, &PyUICollectionIterType, &PyUIEntityCollectionType, &PyUIEntityCollectionIterType, diff --git a/tests/demo/screens/navigation_demo.py b/tests/demo/screens/navigation_demo.py new file mode 100644 index 0000000..4a172b6 --- /dev/null +++ b/tests/demo/screens/navigation_demo.py @@ -0,0 +1,393 @@ +# navigation_demo.py - Visual demo of 3D navigation system +# Shows pathfinding and FOV on terrain using VoxelPoint grid +# Includes 2D Grid minimap with ColorLayer visualization + +import mcrfpy +import sys +import math + +# Grid size +GRID_W, GRID_H = 40, 40 + +# Create demo scene +scene = mcrfpy.Scene("navigation_demo") + +# Dark background frame +bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(15, 15, 25)) +scene.children.append(bg) + +# Title +title = mcrfpy.Caption(text="Navigation System Demo - Pathfinding & FOV", pos=(20, 10)) +title.fill_color = mcrfpy.Color(255, 255, 255) +scene.children.append(title) + +# Create the 3D viewport (left side, smaller) +viewport = mcrfpy.Viewport3D( + pos=(20, 50), + size=(480, 360), + render_resolution=(320, 240), + fov=60.0, + camera_pos=(20.0, 35.0, 50.0), + camera_target=(20.0, 0.0, 20.0), + bg_color=mcrfpy.Color(100, 150, 200) +) +scene.children.append(viewport) + +# Generate terrain using HeightMap +print("Generating terrain heightmap...") +hm = mcrfpy.HeightMap((GRID_W, GRID_H)) +hm.mid_point_displacement(0.5, seed=42) +hm.normalize(0.0, 1.0) +hm.rain_erosion(drops=500, erosion=0.08, sedimentation=0.04, seed=42) +hm.normalize(0.0, 1.0) + +# Shift terrain up so most is walkable land instead of water +# Add 0.3 offset, then renormalize to 0.1-1.0 range (keeps some water) +for y in range(GRID_H): + for x in range(GRID_W): + hm[x, y] = min(1.0, hm[x, y] + 0.3) + +# Build terrain mesh +print("Building terrain mesh...") +vertex_count = viewport.build_terrain( + layer_name="terrain", + heightmap=hm, + y_scale=8.0, + cell_size=1.0 +) +print(f"Terrain built with {vertex_count} vertices") + +# Create color maps based on height for 3D terrain +r_map = mcrfpy.HeightMap((GRID_W, GRID_H)) +g_map = mcrfpy.HeightMap((GRID_W, GRID_H)) +b_map = mcrfpy.HeightMap((GRID_W, GRID_H)) + +for y in range(GRID_H): + for x in range(GRID_W): + h = hm[x, y] + if h < 0.25: # Water (unwalkable) + r_map[x, y] = 0.2 + g_map[x, y] = 0.4 + b_map[x, y] = 0.8 + elif h < 0.6: # Grass (walkable) + r_map[x, y] = 0.2 + h * 0.2 + g_map[x, y] = 0.5 + h * 0.2 + b_map[x, y] = 0.1 + elif h < 0.8: # Hills (walkable but costly) + r_map[x, y] = 0.5 + g_map[x, y] = 0.4 + b_map[x, y] = 0.3 + else: # Mountains (unwalkable) + r_map[x, y] = 0.6 + g_map[x, y] = 0.6 + b_map[x, y] = 0.6 + +viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) + +# Initialize navigation grid +print("Setting up navigation grid...") +viewport.grid_size = (GRID_W, GRID_H) +viewport.cell_size = 1.0 + +# Apply heights from heightmap +viewport.apply_heightmap(hm, y_scale=8.0) + +# Mark water as unwalkable (height < 0.25) +viewport.apply_threshold(hm, 0.0, 0.25, walkable=False) + +# Mark mountains as unwalkable (height > 0.8) +viewport.apply_threshold(hm, 0.8, 1.0, walkable=False) + +# Apply slope costs +viewport.set_slope_cost(max_slope=2.0, cost_multiplier=3.0) + +# ============================================================================ +# Create 2D Grid with ColorLayers (minimap on the right) +# ============================================================================ +print("Creating 2D minimap grid...") + +# Calculate cell size for minimap to fit nicely +minimap_width = 320 +minimap_height = 320 +cell_px = minimap_width // GRID_W # 8 pixels per cell + +# Create 2D Grid (no texture needed for color layers) +grid_2d = mcrfpy.Grid( + grid_size=(GRID_W, GRID_H), + pos=(520, 50), + size=(minimap_width, minimap_height) +) +scene.children.append(grid_2d) + +# Create and add terrain ColorLayer (z_index=0, bottom layer) +terrain_layer = mcrfpy.ColorLayer(z_index=0, name="terrain") +grid_2d.add_layer(terrain_layer) + +# Fill terrain layer with colors matching the heightmap +for y in range(GRID_H): + for x in range(GRID_W): + h = hm[x, y] + if h < 0.25: # Water + terrain_layer.set((x, y), mcrfpy.Color(50, 100, 200)) + elif h < 0.6: # Grass + green = int(100 + h * 100) + terrain_layer.set((x, y), mcrfpy.Color(50, green, 30)) + elif h < 0.8: # Hills + terrain_layer.set((x, y), mcrfpy.Color(130, 100, 70)) + else: # Mountains + terrain_layer.set((x, y), mcrfpy.Color(150, 150, 150)) + +# Create and add path ColorLayer (z_index=1, on top of terrain) +path_layer = mcrfpy.ColorLayer(z_index=1, name="path") +grid_2d.add_layer(path_layer) +# Initialize transparent +path_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + +# Create and add FOV ColorLayer (z_index=2, on top of path) +fov_layer = mcrfpy.ColorLayer(z_index=2, name="fov") +grid_2d.add_layer(fov_layer) +# Initialize transparent +fov_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + +# ============================================================================ +# State for demo +# ============================================================================ +path_start = [10, 20] +path_end = [30, 20] +current_path = [] +fov_visible = [] +show_fov = [True] +show_path = [True] + +def clear_path_layer(): + """Clear the path visualization layer""" + path_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + +def clear_fov_layer(): + """Clear the FOV visualization layer""" + fov_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + +def update_path_visualization(): + """Update path layer to show current path""" + clear_path_layer() + if not show_path[0]: + return + + # Draw path in yellow + for x, z in current_path: + path_layer.set((x, z), mcrfpy.Color(255, 255, 0, 200)) + + # Draw start point in green + path_layer.set((path_start[0], path_start[1]), mcrfpy.Color(0, 255, 0, 255)) + + # Draw end point in red + path_layer.set((path_end[0], path_end[1]), mcrfpy.Color(255, 0, 0, 255)) + +def update_fov_visualization(): + """Update FOV layer to show visible cells""" + clear_fov_layer() + if not show_fov[0]: + return + + # Draw visible cells in semi-transparent blue + for x, z in fov_visible: + # Don't overwrite start/end markers + if [x, z] != path_start and [x, z] != path_end: + fov_layer.set((x, z), mcrfpy.Color(100, 200, 255, 80)) + +def update_path(): + """Recompute path between start and end""" + global current_path + current_path = viewport.find_path(tuple(path_start), tuple(path_end)) + print(f"Path: {len(current_path)} steps") + update_path_visualization() + +def update_fov(): + """Recompute FOV from start position""" + global fov_visible + fov_visible = viewport.compute_fov(tuple(path_start), radius=8) + print(f"FOV: {len(fov_visible)} cells visible") + update_fov_visualization() + +# Initial computation +update_path() +update_fov() + +# ============================================================================ +# Info panel +# ============================================================================ +info_y = 390 + +# Status labels +start_label = mcrfpy.Caption(text=f"Start (green): ({path_start[0]}, {path_start[1]})", pos=(520, info_y)) +start_label.fill_color = mcrfpy.Color(100, 255, 100) +scene.children.append(start_label) + +end_label = mcrfpy.Caption(text=f"End (red): ({path_end[0]}, {path_end[1]})", pos=(520, info_y + 22)) +end_label.fill_color = mcrfpy.Color(255, 100, 100) +scene.children.append(end_label) + +path_label = mcrfpy.Caption(text=f"Path (yellow): {len(current_path)} steps", pos=(520, info_y + 44)) +path_label.fill_color = mcrfpy.Color(255, 255, 100) +scene.children.append(path_label) + +fov_label = mcrfpy.Caption(text=f"FOV (blue): {len(fov_visible)} cells", pos=(520, info_y + 66)) +fov_label.fill_color = mcrfpy.Color(100, 200, 255) +scene.children.append(fov_label) + +# Terrain legend +legend_y = info_y + 100 +legend_title = mcrfpy.Caption(text="Terrain Legend:", pos=(520, legend_y)) +legend_title.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(legend_title) + +legends = [ + ("Water (blue)", mcrfpy.Color(50, 100, 200), "unwalkable"), + ("Grass (green)", mcrfpy.Color(80, 150, 30), "walkable"), + ("Hills (brown)", mcrfpy.Color(130, 100, 70), "costly"), + ("Mountains (gray)", mcrfpy.Color(150, 150, 150), "unwalkable"), +] + +for i, (name, color, desc) in enumerate(legends): + cap = mcrfpy.Caption(text=f" {name}: {desc}", pos=(520, legend_y + 22 + i * 20)) + cap.fill_color = color + scene.children.append(cap) + +# Instructions +instructions = mcrfpy.Caption( + text="[WASD] Move start | [IJKL] Move end | [F] FOV | [P] Path | [Space] Orbit", + pos=(20, 430) +) +instructions.fill_color = mcrfpy.Color(150, 150, 150) +scene.children.append(instructions) + +# Status line +status = mcrfpy.Caption(text="Status: Navigation demo ready - orbit OFF", pos=(20, 455)) +status.fill_color = mcrfpy.Color(100, 200, 100) +scene.children.append(status) + +# ============================================================================ +# Animation state - orbit disabled by default +# ============================================================================ +animation_time = [0.0] +camera_orbit = [False] # Disabled by default + +def update_display(): + """Update info display""" + start_label.text = f"Start (green): ({path_start[0]}, {path_start[1]})" + end_label.text = f"End (red): ({path_end[0]}, {path_end[1]})" + path_label.text = f"Path (yellow): {len(current_path)} steps" + fov_label.text = f"FOV (blue): {len(fov_visible)} cells" + +# Camera animation +def update_camera(timer, runtime): + animation_time[0] += runtime / 1000.0 + + if camera_orbit[0]: + angle = animation_time[0] * 0.2 + radius = 35.0 + center_x = 20.0 + center_z = 20.0 + height = 25.0 + math.sin(animation_time[0] * 0.15) * 5.0 + + x = center_x + math.cos(angle) * radius + z = center_z + math.sin(angle) * radius + + viewport.camera_pos = (x, height, z) + viewport.camera_target = (center_x, 2.0, center_z) + +# Key handler +def on_key(key, state): + if state != mcrfpy.InputState.PRESSED: + return + + global current_path, fov_visible + + # Movement for start point (WASD) + moved_start = False + if key == mcrfpy.Key.W: + path_start[1] = max(0, path_start[1] - 1) + moved_start = True + elif key == mcrfpy.Key.S: + path_start[1] = min(GRID_H - 1, path_start[1] + 1) + moved_start = True + elif key == mcrfpy.Key.A: + path_start[0] = max(0, path_start[0] - 1) + moved_start = True + elif key == mcrfpy.Key.D: + path_start[0] = min(GRID_W - 1, path_start[0] + 1) + moved_start = True + + # Movement for end point (IJKL) + moved_end = False + if key == mcrfpy.Key.I: + path_end[1] = max(0, path_end[1] - 1) + moved_end = True + elif key == mcrfpy.Key.K: + path_end[1] = min(GRID_H - 1, path_end[1] + 1) + moved_end = True + elif key == mcrfpy.Key.J: + path_end[0] = max(0, path_end[0] - 1) + moved_end = True + elif key == mcrfpy.Key.L: + path_end[0] = min(GRID_W - 1, path_end[0] + 1) + moved_end = True + + # Toggle FOV display + if key == mcrfpy.Key.F: + show_fov[0] = not show_fov[0] + update_fov_visualization() + status.text = f"FOV display: {'ON' if show_fov[0] else 'OFF'}" + + # Toggle path display + if key == mcrfpy.Key.P: + show_path[0] = not show_path[0] + update_path_visualization() + status.text = f"Path display: {'ON' if show_path[0] else 'OFF'}" + + # Toggle camera orbit + if key == mcrfpy.Key.SPACE: + camera_orbit[0] = not camera_orbit[0] + status.text = f"Camera orbit: {'ON' if camera_orbit[0] else 'OFF'}" + + # Quit + if key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + + # Update pathfinding and FOV if moved + if moved_start or moved_end: + update_path() + if moved_start: + update_fov() + update_display() + + # Show cell info + vp = viewport.at(path_start[0], path_start[1]) + status.text = f"Start cell: walkable={vp.walkable}, height={vp.height:.2f}, cost={vp.cost:.2f}" + +# Set up scene +scene.on_key = on_key + +# Create timer for camera animation +timer = mcrfpy.Timer("camera_update", update_camera, 16) + +# Activate scene +mcrfpy.current_scene = scene + +print() +print("Navigation Demo loaded!") +print(f"A {GRID_W}x{GRID_H} terrain with VoxelPoint navigation grid.") +print() +print("Left: 3D terrain view") +print("Right: 2D minimap with ColorLayer overlays") +print(" - Terrain layer shows heightmap colors") +print(" - Path layer shows computed A* path (yellow)") +print(" - FOV layer shows visible cells (blue tint)") +print() +print("Controls:") +print(" [WASD] Move start point (green)") +print(" [IJKL] Move end point (red)") +print(" [F] Toggle FOV display") +print(" [P] Toggle path display") +print(" [Space] Toggle camera orbit") +print(" [ESC] Quit")