feat(engine): implement perspective FOV, pathfinding, and GUI text widgets

Major Engine Enhancements:
- Complete FOV (Field of View) system with perspective rendering
  - UIGrid.perspective property for entity-based visibility
  - Three-layer overlay colors (unexplored, explored, visible)
  - Per-entity visibility state tracking
  - Perfect knowledge updates only for explored areas

- Advanced Pathfinding Integration
  - A* pathfinding implementation in UIGrid
  - Entity.path_to() method for direct pathfinding
  - Dijkstra maps for multi-target pathfinding
  - Path caching for performance optimization

- GUI Text Input Widgets
  - TextInputWidget class with cursor, selection, scrolling
  - Improved widget with proper text rendering and input handling
  - Example showcase of multiple text input fields
  - Foundation for in-game console and chat systems

- Performance & Architecture Improvements
  - PyTexture copy operations optimized
  - GameEngine update cycle refined
  - UIEntity property handling enhanced
  - UITestScene modernized

Test Suite:
- Interactive visibility demos showing FOV in action
- Pathfinding comparison (A* vs Dijkstra)
- Debug utilities for visibility and empty path handling
- Sizzle reel demo combining pathfinding and vision
- Multiple text input test scenarios

This commit brings McRogueFace closer to a complete roguelike engine
with essential features like line-of-sight, intelligent pathfinding,
and interactive text input capabilities.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-07-09 22:18:29 -04:00
commit d13153ddb4
25 changed files with 3317 additions and 225 deletions

View file

@ -7,7 +7,8 @@
UIGrid::UIGrid()
: grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr) // Default dark gray background
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective(-1) // Default to omniscient view
{
// Initialize entities list
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
@ -34,7 +35,8 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
: grid_x(gx), grid_y(gy),
zoom(1.0f),
ptex(_ptex), points(gx * gy),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr) // Default dark gray background
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective(-1) // Default to omniscient view
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
@ -70,6 +72,9 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
// Create TCOD dijkstra pathfinder
tcod_dijkstra = new TCODDijkstra(tcod_map);
// Create TCOD A* pathfinder
tcod_path = new TCODPath(tcod_map);
// Initialize grid points with parent reference
for (int y = 0; y < gy; y++) {
for (int x = 0; x < gx; x++) {
@ -183,43 +188,55 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
}
// top layer - opacity for discovered / visible status (debug, basically)
/* // Disabled until I attach a "perspective"
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit; //x < view_width;
x+=1)
{
//for (float y = (top_edge >= 0 ? top_edge : 0);
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit; //y < view_height;
y+=1)
// top layer - opacity for discovered / visible status based on perspective
// Only render visibility overlay if perspective is set (not omniscient)
if (perspective >= 0 && perspective < static_cast<int>(entities->size())) {
// Get the entity whose perspective we're using
auto it = entities->begin();
std::advance(it, perspective);
auto& entity = *it;
// Create rectangle for overlays
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
auto pixel_pos = sf::Vector2f(
(x*itex->grid_size - left_spritepixels) * zoom,
(y*itex->grid_size - top_spritepixels) * zoom );
auto gridpoint = at(std::floor(x), std::floor(y));
sprite.setPosition(pixel_pos);
r.setPosition(pixel_pos);
// visible & discovered layers for testing purposes
if (!gridpoint.discovered) {
r.setFillColor(sf::Color(16, 16, 20, 192)); // 255 opacity for actual blackout
renderTexture.draw(r);
} else if (!gridpoint.visible) {
r.setFillColor(sf::Color(32, 32, 40, 128));
renderTexture.draw(r);
// Get visibility state from entity's perspective
int idx = y * grid_x + x;
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
const auto& state = entity->gridstate[idx];
overlay.setPosition(pixel_pos);
// Three overlay colors as specified:
if (!state.discovered) {
// Never seen - black
overlay.setFillColor(sf::Color(0, 0, 0, 255));
renderTexture.draw(overlay);
} else if (!state.visible) {
// Discovered but not currently visible - dark gray
overlay.setFillColor(sf::Color(32, 32, 40, 192));
renderTexture.draw(overlay);
}
// If visible and discovered, no overlay (fully visible)
}
}
// overlay
// uisprite
}
}
*/
// grid lines for testing & validation
/*
@ -255,6 +272,10 @@ UIGridPoint& UIGrid::at(int x, int y)
UIGrid::~UIGrid()
{
if (tcod_path) {
delete tcod_path;
tcod_path = nullptr;
}
if (tcod_dijkstra) {
delete tcod_dijkstra;
tcod_dijkstra = nullptr;
@ -363,6 +384,41 @@ std::vector<std::pair<int, int>> UIGrid::getDijkstraPath(int x, int y) const
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_x || y1 < 0 || y1 >= grid_y ||
x2 < 0 || x2 >= grid_x || y2 < 0 || y2 >= grid_y) {
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
sf::FloatRect UIGrid::get_bounds() const
{
@ -876,6 +932,38 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
return 0;
}
PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure)
{
return PyLong_FromLong(self->data->perspective);
}
int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure)
{
long perspective = PyLong_AsLong(value);
if (PyErr_Occurred()) {
return -1;
}
// Validate perspective (-1 for omniscient, or valid entity index)
if (perspective < -1) {
PyErr_SetString(PyExc_ValueError, "perspective must be -1 (omniscient) or a valid entity index");
return -1;
}
// Check if entity index is valid (if not omniscient)
if (perspective >= 0 && self->data->entities) {
int entity_count = self->data->entities->size();
if (perspective >= entity_count) {
PyErr_Format(PyExc_IndexError, "perspective index %ld out of range (grid has %d entities)",
perspective, entity_count);
return -1;
}
}
self->data->perspective = perspective;
return 0;
}
// Python API implementations for TCOD functionality
PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
@ -980,6 +1068,31 @@ PyObject* UIGrid::py_get_dijkstra_path(PyUIGridObject* self, PyObject* args)
return path_list;
}
PyObject* UIGrid::py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
int x1, y1, x2, y2;
float diagonal_cost = 1.41f;
static char* kwlist[] = {"x1", "y1", "x2", "y2", "diagonal_cost", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiii|f", kwlist,
&x1, &y1, &x2, &y2, &diagonal_cost)) {
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;
}
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
@ -994,6 +1107,8 @@ PyMethodDef UIGrid::methods[] = {
"Get distance from Dijkstra root to position. Args: x, y. Returns float or None if invalid."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS,
"Get path from position to Dijkstra root. Args: x, y. Returns list of (x,y) tuples."},
{"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS,
"Compute A* path between two points. Args: x1, y1, x2, y2, diagonal_cost=1.41. Returns list of (x,y) tuples. Note: diagonal_cost is currently ignored (uses default 1.41)."},
{NULL, NULL, 0, NULL}
};
@ -1016,6 +1131,8 @@ PyMethodDef UIGrid_all_methods[] = {
"Get distance from Dijkstra root to position. Args: x, y. Returns float or None if invalid."},
{"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS,
"Get path from position to Dijkstra root. Args: x, y. Returns list of (x,y) tuples."},
{"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS,
"Compute A* path between two points. Args: x1, y1, x2, y2, diagonal_cost=1.41. Returns list of (x,y) tuples. Note: diagonal_cost is currently ignored (uses default 1.41)."},
{NULL} // Sentinel
};
@ -1044,6 +1161,7 @@ PyGetSetDef UIGrid::getsetters[] = {
{"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, //TODO 7DRL-day2-item5
{"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, "Background fill color of the grid", NULL},
{"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective, "Entity perspective index (-1 for omniscient view)", NULL},
{"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int, "Z-order for rendering (lower values rendered first)", (void*)PyObjectsEnum::UIGRID},
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID},
UIDRAWABLE_GETSETTERS,
@ -1386,6 +1504,16 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
PyUIEntityObject* entity = (PyUIEntityObject*)o;
self->data->push_back(entity->data);
entity->data->grid = self->grid;
// Initialize gridstate if not already done
if (entity->data->gridstate.size() == 0 && self->grid) {
entity->data->gridstate.resize(self->grid->grid_x * self->grid->grid_y);
// Initialize all cells as not visible/discovered
for (auto& state : entity->data->gridstate) {
state.visible = false;
state.discovered = false;
}
}
Py_INCREF(Py_None);
return Py_None;