Replace UIEntity gridstate with DiscreteMap perspective_map; closes #294
Per-entity FOV memory moves from std::vector<UIGridPointState> (two-bool visible/discovered pairs) to a 3-state DiscreteMap (0=UNKNOWN, 1=DISCOVERED, 2=VISIBLE), exposed as entity.perspective_map. The invariant visible-subset-of-discovered becomes structural (single value per cell), and the map is a live, serializable, first-class object rather than an implicit internal array. Changes: - New DiscreteMap C++ class with shared ownership; PyDiscreteMapObject now holds shared_ptr<DiscreteMap>. UIEntity holds the same shared_ptr. - New mcrfpy.Perspective IntEnum (UNKNOWN/DISCOVERED/VISIBLE), modelled on PyInputState. - entity.perspective_map: lazy-allocated on first access with a grid; setter validates size against grid and raises ValueError on mismatch; None clears (next access lazy-reallocates fresh). - updateVisibility() now demotes 2->1 then promotes visible cells to 2. - entity.at(x, y) returns grid.at(x, y) when VISIBLE, else None. - Fog-of-war rendering in UIGridView and UIGrid reads the 3-state map. - Removed: UIEntity::gridstate, ensureGridstate(), entity.gridstate getter, UIGridPointState struct + PyUIGridPointStateType. - Obsolete tests deleted (test_gridpointstate_point, issue_265_gridpointstate_dangle); 4 new tests cover lazy allocation, identity, serialization round-trip, size validation, and the visible-subset-of-discovered invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
417fc43325
commit
f797120d53
24 changed files with 7998 additions and 1906 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2662
docs/mcrfpy.3
2662
docs/mcrfpy.3
File diff suppressed because it is too large
Load diff
27
src/DiscreteMap.cpp
Normal file
27
src/DiscreteMap.cpp
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#include "DiscreteMap.h"
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
|
||||
DiscreteMap::DiscreteMap(int w, int h, uint8_t fill)
|
||||
: w_(w), h_(h), values_(nullptr)
|
||||
{
|
||||
if (w_ <= 0 || h_ <= 0) {
|
||||
throw std::invalid_argument("DiscreteMap dimensions must be positive");
|
||||
}
|
||||
size_t total = static_cast<size_t>(w_) * static_cast<size_t>(h_);
|
||||
values_ = new uint8_t[total];
|
||||
std::memset(values_, fill, total);
|
||||
}
|
||||
|
||||
DiscreteMap::~DiscreteMap()
|
||||
{
|
||||
delete[] values_;
|
||||
}
|
||||
|
||||
void DiscreteMap::demoteVisible()
|
||||
{
|
||||
size_t total = size();
|
||||
for (size_t i = 0; i < total; ++i) {
|
||||
if (values_[i] == 2) values_[i] = 1;
|
||||
}
|
||||
}
|
||||
35
src/DiscreteMap.h
Normal file
35
src/DiscreteMap.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// DiscreteMap - a dense uint8 grid owning its buffer.
|
||||
//
|
||||
// Shared-ownership C++ type wrapped by PyDiscreteMapObject and also held by
|
||||
// UIEntity for per-entity perspective (issue #294). Dimensions are fixed at
|
||||
// construction; use size() / width() / height() for bounds checks.
|
||||
class DiscreteMap {
|
||||
public:
|
||||
DiscreteMap(int w, int h, uint8_t fill = 0);
|
||||
~DiscreteMap();
|
||||
|
||||
DiscreteMap(const DiscreteMap&) = delete;
|
||||
DiscreteMap& operator=(const DiscreteMap&) = delete;
|
||||
|
||||
int width() const { return w_; }
|
||||
int height() const { return h_; }
|
||||
size_t size() const { return static_cast<size_t>(w_) * static_cast<size_t>(h_); }
|
||||
|
||||
uint8_t* data() { return values_; }
|
||||
const uint8_t* data() const { return values_; }
|
||||
|
||||
// Demote every cell with value == 2 back to 1. Used at the start of
|
||||
// UIEntity::updateVisibility() -- the 3-state perspective model promotes
|
||||
// freshly-visible cells from 1 (or 0) to 2, and this call handles the
|
||||
// per-tick demotion of "was visible last tick" to "discovered".
|
||||
void demoteVisible();
|
||||
|
||||
private:
|
||||
int w_;
|
||||
int h_;
|
||||
uint8_t* values_;
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
#include "PyKey.h"
|
||||
#include "PyMouseButton.h"
|
||||
#include "PyInputState.h"
|
||||
#include "PyPerspective.h"
|
||||
#include "PyBehavior.h"
|
||||
#include "PyTrigger.h"
|
||||
#include "UIGridView.h"
|
||||
|
|
@ -120,7 +121,7 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args)
|
|||
#ifdef __EMSCRIPTEN__
|
||||
sync_storage();
|
||||
#endif
|
||||
// On desktop, writes go directly to disk — nothing to sync
|
||||
// On desktop, writes go directly to disk -- nothing to sync
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
|
|
@ -550,8 +551,8 @@ PyObject* PyInit_mcrfpy()
|
|||
/*#252: internal grid data type - UIGrid is now internal, GridView is "Grid"*/
|
||||
&PyUIGridType,
|
||||
|
||||
/*game map & perspective data - returned by Grid.at() but not directly instantiable*/
|
||||
&PyUIGridPointType, &PyUIGridPointStateType,
|
||||
/*game map data - returned by Grid.at() but not directly instantiable*/
|
||||
&PyUIGridPointType,
|
||||
|
||||
/*3D navigation grid - returned by Viewport3D.at() but not directly instantiable*/
|
||||
&mcrfpydef::PyVoxelPointType,
|
||||
|
|
@ -786,6 +787,12 @@ PyObject* PyInit_mcrfpy()
|
|||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Perspective enum class for entity perspective_map values (#294)
|
||||
PyObject* perspective_class = PyPerspective::create_enum_class(m);
|
||||
if (!perspective_class) {
|
||||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Alignment enum class for automatic child positioning
|
||||
PyObject* alignment_class = PyAlignment::create_enum_class(m);
|
||||
if (!alignment_class) {
|
||||
|
|
|
|||
|
|
@ -368,6 +368,9 @@ PyObject* PyDiscreteMap::pynew(PyTypeObject* type, PyObject* args, PyObject* kwd
|
|||
{
|
||||
PyDiscreteMapObject* self = (PyDiscreteMapObject*)type->tp_alloc(type, 0);
|
||||
if (self) {
|
||||
// Placement-new the shared_ptr member; tp_alloc zeroed memory but
|
||||
// shared_ptr requires proper construction before assignment.
|
||||
new (&self->data) std::shared_ptr<DiscreteMap>();
|
||||
self->values = nullptr;
|
||||
self->w = 0;
|
||||
self->h = 0;
|
||||
|
|
@ -419,27 +422,24 @@ int PyDiscreteMap::init(PyDiscreteMapObject* self, PyObject* args, PyObject* kwd
|
|||
return -1;
|
||||
}
|
||||
|
||||
// Clean up any existing data
|
||||
if (self->values) {
|
||||
delete[] self->values;
|
||||
self->values = nullptr;
|
||||
}
|
||||
// Reset any existing storage (re-init supported)
|
||||
self->data.reset();
|
||||
Py_XDECREF(self->enum_type);
|
||||
self->enum_type = nullptr;
|
||||
|
||||
// Allocate new array
|
||||
size_t total_size = static_cast<size_t>(width) * static_cast<size_t>(height);
|
||||
self->values = new (std::nothrow) uint8_t[total_size];
|
||||
if (!self->values) {
|
||||
// Construct shared-ownership C++ storage (issue #294)
|
||||
try {
|
||||
self->data = std::make_shared<DiscreteMap>(
|
||||
width, height, static_cast<uint8_t>(fill_value));
|
||||
} catch (const std::bad_alloc&) {
|
||||
PyErr_SetString(PyExc_MemoryError, "Failed to allocate DiscreteMap");
|
||||
return -1;
|
||||
}
|
||||
|
||||
self->w = width;
|
||||
self->h = height;
|
||||
|
||||
// Fill with initial value
|
||||
memset(self->values, static_cast<uint8_t>(fill_value), total_size);
|
||||
// Cache non-owning views for hot-path access
|
||||
self->values = self->data->data();
|
||||
self->w = self->data->width();
|
||||
self->h = self->data->height();
|
||||
|
||||
// Store enum type if provided
|
||||
if (enum_obj && enum_obj != Py_None) {
|
||||
|
|
@ -452,10 +452,10 @@ int PyDiscreteMap::init(PyDiscreteMapObject* self, PyObject* args, PyObject* kwd
|
|||
|
||||
void PyDiscreteMap::dealloc(PyDiscreteMapObject* self)
|
||||
{
|
||||
if (self->values) {
|
||||
delete[] self->values;
|
||||
self->values = nullptr;
|
||||
}
|
||||
// Release shared ownership; DiscreteMap destructor frees the buffer
|
||||
// if this is the last owner.
|
||||
self->data.~shared_ptr<DiscreteMap>();
|
||||
self->values = nullptr;
|
||||
Py_XDECREF(self->enum_type);
|
||||
self->enum_type = nullptr;
|
||||
Py_TYPE(self)->tp_free((PyObject*)self);
|
||||
|
|
@ -1387,14 +1387,19 @@ PyObject* PyDiscreteMap::from_bytes(PyTypeObject* type, PyObject* args, PyObject
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
obj->w = w;
|
||||
obj->h = h;
|
||||
obj->values = new (std::nothrow) uint8_t[expected];
|
||||
if (!obj->values) {
|
||||
new (&obj->data) std::shared_ptr<DiscreteMap>();
|
||||
try {
|
||||
obj->data = std::make_shared<DiscreteMap>(w, h, 0);
|
||||
} catch (const std::bad_alloc&) {
|
||||
PyBuffer_Release(&buffer);
|
||||
obj->data.~shared_ptr<DiscreteMap>();
|
||||
Py_DECREF(obj);
|
||||
return PyErr_NoMemory();
|
||||
}
|
||||
|
||||
obj->w = w;
|
||||
obj->h = h;
|
||||
obj->values = obj->data->data();
|
||||
std::memcpy(obj->values, buffer.buf, expected);
|
||||
PyBuffer_Release(&buffer);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include "DiscreteMap.h"
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
// Forward declaration
|
||||
class PyDiscreteMap;
|
||||
|
||||
// Python object structure
|
||||
// Python object structure.
|
||||
//
|
||||
// `data` owns the underlying buffer (shared with UIEntity::perspective_map
|
||||
// when this DiscreteMap is an entity's perspective). `values`/`w`/`h` are
|
||||
// cached non-owning views that point into `data` for fast hot-path access --
|
||||
// they are populated in init() / from_bytes() / the perspective-map getter
|
||||
// on UIEntity, and remain valid for the lifetime of this Python object
|
||||
// because `data`'s dimensions are immutable after construction.
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
uint8_t* values; // Row-major array (width * height)
|
||||
int w, h; // Dimensions (max 8192x8192)
|
||||
PyObject* enum_type; // Optional Python IntEnum for value interpretation
|
||||
std::shared_ptr<DiscreteMap> data; // shared-ownership C++ storage (issue #294)
|
||||
uint8_t* values; // cached data->data()
|
||||
int w, h; // cached data->width()/height()
|
||||
PyObject* enum_type; // Optional Python IntEnum for value interpretation
|
||||
} PyDiscreteMapObject;
|
||||
|
||||
class PyDiscreteMap
|
||||
|
|
|
|||
127
src/PyPerspective.cpp
Normal file
127
src/PyPerspective.cpp
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#include "PyPerspective.h"
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
PyObject* PyPerspective::perspective_enum_class = nullptr;
|
||||
|
||||
struct PerspectiveEntry {
|
||||
const char* name;
|
||||
int value;
|
||||
};
|
||||
|
||||
static const PerspectiveEntry perspective_table[] = {
|
||||
{"UNKNOWN", 0},
|
||||
{"DISCOVERED", 1},
|
||||
{"VISIBLE", 2},
|
||||
};
|
||||
|
||||
static const int NUM_PERSPECTIVE_ENTRIES =
|
||||
sizeof(perspective_table) / sizeof(perspective_table[0]);
|
||||
|
||||
PyObject* PyPerspective::create_enum_class(PyObject* module) {
|
||||
std::ostringstream code;
|
||||
code << "from enum import IntEnum\n\n";
|
||||
code << "class Perspective(IntEnum):\n";
|
||||
code << " \"\"\"Enum representing an entity's knowledge of a cell.\n";
|
||||
code << " \n";
|
||||
code << " Values:\n";
|
||||
code << " UNKNOWN: Never seen (perspective_map value 0)\n";
|
||||
code << " DISCOVERED: Seen before but not currently visible (value 1)\n";
|
||||
code << " VISIBLE: In current FOV (value 2)\n";
|
||||
code << " \"\"\"\n";
|
||||
for (int i = 0; i < NUM_PERSPECTIVE_ENTRIES; i++) {
|
||||
code << " " << perspective_table[i].name
|
||||
<< " = " << perspective_table[i].value << "\n";
|
||||
}
|
||||
code << "\n";
|
||||
code << "Perspective.__hash__ = lambda self: hash(int(self))\n";
|
||||
code << "Perspective.__repr__ = lambda self: f\"{type(self).__name__}.{self.name}\"\n";
|
||||
code << "Perspective.__str__ = lambda self: self.name\n";
|
||||
|
||||
std::string code_str = code.str();
|
||||
|
||||
PyObject* globals = PyDict_New();
|
||||
if (!globals) return NULL;
|
||||
PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins());
|
||||
|
||||
PyObject* locals = PyDict_New();
|
||||
if (!locals) { Py_DECREF(globals); return NULL; }
|
||||
|
||||
PyObject* result = PyRun_String(code_str.c_str(), Py_file_input, globals, locals);
|
||||
if (!result) {
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
return NULL;
|
||||
}
|
||||
Py_DECREF(result);
|
||||
|
||||
PyObject* enum_class = PyDict_GetItemString(locals, "Perspective");
|
||||
if (!enum_class) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Failed to create Perspective enum class");
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
return NULL;
|
||||
}
|
||||
Py_INCREF(enum_class);
|
||||
|
||||
perspective_enum_class = enum_class;
|
||||
Py_INCREF(perspective_enum_class);
|
||||
|
||||
if (PyModule_AddObject(module, "Perspective", enum_class) < 0) {
|
||||
Py_DECREF(enum_class);
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
perspective_enum_class = nullptr;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
return enum_class;
|
||||
}
|
||||
|
||||
int PyPerspective::from_arg(PyObject* arg, uint8_t* out_value) {
|
||||
if (perspective_enum_class && PyObject_IsInstance(arg, perspective_enum_class)) {
|
||||
PyObject* value = PyObject_GetAttrString(arg, "value");
|
||||
if (!value) return 0;
|
||||
long val = PyLong_AsLong(value);
|
||||
Py_DECREF(value);
|
||||
if (val == -1 && PyErr_Occurred()) return 0;
|
||||
if (val < 0 || val > 2) {
|
||||
PyErr_Format(PyExc_ValueError, "Invalid Perspective value: %ld", val);
|
||||
return 0;
|
||||
}
|
||||
*out_value = static_cast<uint8_t>(val);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (PyLong_Check(arg)) {
|
||||
long val = PyLong_AsLong(arg);
|
||||
if (val == -1 && PyErr_Occurred()) return 0;
|
||||
if (val < 0 || val > 2) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"Invalid Perspective value: %ld. Must be 0, 1, or 2.", val);
|
||||
return 0;
|
||||
}
|
||||
*out_value = static_cast<uint8_t>(val);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (PyUnicode_Check(arg)) {
|
||||
const char* name = PyUnicode_AsUTF8(arg);
|
||||
if (!name) return 0;
|
||||
for (int i = 0; i < NUM_PERSPECTIVE_ENTRIES; i++) {
|
||||
if (strcmp(name, perspective_table[i].name) == 0) {
|
||||
*out_value = static_cast<uint8_t>(perspective_table[i].value);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"Unknown Perspective: '%s'. Use UNKNOWN, DISCOVERED, or VISIBLE.", name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyErr_SetString(PyExc_TypeError,
|
||||
"Perspective must be mcrfpy.Perspective enum member, string, or int");
|
||||
return 0;
|
||||
}
|
||||
33
src/PyPerspective.h
Normal file
33
src/PyPerspective.h
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include <cstdint>
|
||||
|
||||
// Module-level Perspective enum class (created at runtime using Python's IntEnum)
|
||||
// Stored as a module attribute: mcrfpy.Perspective
|
||||
//
|
||||
// Values:
|
||||
// UNKNOWN = 0 (never seen)
|
||||
// DISCOVERED = 1 (seen before, not currently visible)
|
||||
// VISIBLE = 2 (in current FOV)
|
||||
//
|
||||
// Used as the value space for UIEntity.perspective_map (issue #294).
|
||||
class PyPerspective {
|
||||
public:
|
||||
// Create the Perspective enum class and add to module.
|
||||
// Returns the enum class (new reference), or NULL on error.
|
||||
static PyObject* create_enum_class(PyObject* module);
|
||||
|
||||
// Helper to extract a Perspective value from a Python arg.
|
||||
// Accepts Perspective enum member, string (enum name), or int 0/1/2.
|
||||
// Returns 1 on success, 0 on error (with exception set).
|
||||
static int from_arg(PyObject* arg, uint8_t* out_value);
|
||||
|
||||
// Cached reference to the Perspective enum class for fast type checking.
|
||||
static PyObject* perspective_enum_class;
|
||||
|
||||
static const int NUM_PERSPECTIVE_STATES = 3;
|
||||
static const uint8_t UNKNOWN = 0;
|
||||
static const uint8_t DISCOVERED = 1;
|
||||
static const uint8_t VISIBLE = 2;
|
||||
};
|
||||
244
src/UIEntity.cpp
244
src/UIEntity.cpp
|
|
@ -9,6 +9,8 @@
|
|||
#include "PyVector.h"
|
||||
#include "PythonObjectCache.h"
|
||||
#include "PyFOV.h"
|
||||
#include "PyDiscreteMap.h" // #294: perspective_map wrapper
|
||||
#include "PyPerspective.h" // #294: VISIBLE constant
|
||||
#include "Animation.h"
|
||||
#include "PyAnimation.h"
|
||||
#include "PyEasing.h"
|
||||
|
|
@ -23,8 +25,8 @@
|
|||
UIEntity::UIEntity()
|
||||
: grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f)
|
||||
{
|
||||
// Initialize sprite with safe defaults (sprite has its own safe constructor now)
|
||||
// gridstate vector starts empty - will be lazily initialized when needed
|
||||
// perspective_map starts null; lazily allocated on first access or
|
||||
// updateVisibility() call once a grid is set (#294).
|
||||
}
|
||||
|
||||
UIEntity::~UIEntity() {
|
||||
|
|
@ -36,30 +38,24 @@ UIEntity::~UIEntity() {
|
|||
|
||||
// Removed UIEntity(UIGrid&) constructor - using lazy initialization instead
|
||||
|
||||
void UIEntity::ensureGridstate()
|
||||
{
|
||||
if (!grid) return;
|
||||
size_t expected = static_cast<size_t>(grid->grid_w) * grid->grid_h;
|
||||
if (gridstate.size() != expected) {
|
||||
gridstate.resize(expected);
|
||||
for (auto& state : gridstate) {
|
||||
state.visible = false;
|
||||
state.discovered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UIEntity::updateVisibility()
|
||||
{
|
||||
if (!grid) return;
|
||||
|
||||
ensureGridstate();
|
||||
|
||||
// First, mark all cells as not visible
|
||||
for (auto& state : gridstate) {
|
||||
state.visible = false;
|
||||
// Lazy-allocate or resize perspective_map if grid dimensions changed.
|
||||
// Dimension mismatch wipes prior state -- the entity's memory has no
|
||||
// identity with a differently-sized grid, so starting fresh is correct.
|
||||
size_t expected = static_cast<size_t>(grid->grid_w) * grid->grid_h;
|
||||
if (!perspective_map || perspective_map->size() != expected) {
|
||||
perspective_map = std::make_shared<DiscreteMap>(grid->grid_w, grid->grid_h, 0);
|
||||
}
|
||||
|
||||
// Demote visible (2) -> discovered (1) from prior tick. The invariant
|
||||
// `visible subset of discovered` is structural in the 3-state model: cells
|
||||
// currently visible will be re-promoted to 2 below, and cells that
|
||||
// just left FOV fall to discovered.
|
||||
perspective_map->demoteVisible();
|
||||
|
||||
// Compute FOV from entity's cell position (#114, #295)
|
||||
int x = cell_position.x;
|
||||
int y = cell_position.y;
|
||||
|
|
@ -67,13 +63,13 @@ void UIEntity::updateVisibility()
|
|||
// Use grid's configured FOV algorithm and radius
|
||||
grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
|
||||
|
||||
// Update visible cells based on FOV computation
|
||||
// Promote visible cells to 2 (VISIBLE). Cells going 0 -> 2 are
|
||||
// freshly discovered; cells going 1 -> 2 were already discovered.
|
||||
uint8_t* buf = perspective_map->data();
|
||||
for (int gy = 0; gy < grid->grid_h; gy++) {
|
||||
for (int gx = 0; gx < grid->grid_w; gx++) {
|
||||
int idx = gy * grid->grid_w + gx;
|
||||
if (grid->isInFOV(gx, gy)) {
|
||||
gridstate[idx].visible = true;
|
||||
gridstate[idx].discovered = true; // Once seen, always discovered
|
||||
buf[gy * grid->grid_w + gx] = PyPerspective::VISIBLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,30 +102,38 @@ void UIEntity::updateVisibility()
|
|||
}
|
||||
|
||||
PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
||||
// #294: at(x, y) returns grid.at(x, y) when the cell is currently VISIBLE
|
||||
// to this entity, None otherwise. Equivalent to:
|
||||
// self.grid.at(x, y) if self.perspective_map[x, y] == Perspective.VISIBLE else None
|
||||
int x, y;
|
||||
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
||||
return NULL; // Error already set by PyPosition_ParseInt
|
||||
}
|
||||
|
||||
if (self->data->grid == NULL) {
|
||||
PyErr_SetString(PyExc_ValueError, "Entity cannot access surroundings because it is not associated with a grid");
|
||||
auto& entity = self->data;
|
||||
if (!entity->grid) {
|
||||
PyErr_SetString(PyExc_ValueError,
|
||||
"Entity cannot access surroundings because it is not associated with a grid");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
self->data->ensureGridstate();
|
||||
|
||||
// Bounds check
|
||||
if (x < 0 || x >= self->data->grid->grid_w || y < 0 || y >= self->data->grid->grid_h) {
|
||||
if (x < 0 || x >= entity->grid->grid_w || y < 0 || y >= entity->grid->grid_h) {
|
||||
PyErr_Format(PyExc_IndexError, "Grid coordinates (%d, %d) out of bounds", x, y);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Use type directly since GridPointState is internal-only (not exported to module)
|
||||
auto type = &mcrfpydef::PyUIGridPointStateType;
|
||||
auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0);
|
||||
obj->grid = self->data->grid;
|
||||
obj->entity = self->data;
|
||||
obj->x = x; // #16 - Store position for .point property
|
||||
// No perspective yet or cell not visible -> None
|
||||
if (!entity->perspective_map) Py_RETURN_NONE;
|
||||
uint8_t state = entity->perspective_map->data()[y * entity->grid->grid_w + x];
|
||||
if (state != PyPerspective::VISIBLE) Py_RETURN_NONE;
|
||||
|
||||
// Construct a GridPoint wrapper (same pattern as grid.at(x, y)).
|
||||
auto type = &mcrfpydef::PyUIGridPointType;
|
||||
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return NULL;
|
||||
obj->grid = entity->grid;
|
||||
obj->x = x;
|
||||
obj->y = y;
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
|
@ -369,41 +373,6 @@ sf::Vector2i PyObject_to_sfVector2i(PyObject* obj) {
|
|||
return sf::Vector2i(static_cast<int>(vec->data.x), static_cast<int>(vec->data.y));
|
||||
}
|
||||
|
||||
PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state) {
|
||||
// Return a simple namespace with visible/discovered attributes
|
||||
// (detached snapshot — not backed by a live entity's gridstate)
|
||||
PyObject* types_mod = PyImport_ImportModule("types");
|
||||
if (!types_mod) return NULL;
|
||||
PyObject* ns_type = PyObject_GetAttrString(types_mod, "SimpleNamespace");
|
||||
Py_DECREF(types_mod);
|
||||
if (!ns_type) return NULL;
|
||||
PyObject* kwargs = Py_BuildValue("{s:O,s:O}",
|
||||
"visible", state.visible ? Py_True : Py_False,
|
||||
"discovered", state.discovered ? Py_True : Py_False);
|
||||
if (!kwargs) { Py_DECREF(ns_type); return NULL; }
|
||||
PyObject* obj = PyObject_Call(ns_type, PyTuple_New(0), kwargs);
|
||||
Py_DECREF(ns_type);
|
||||
Py_DECREF(kwargs);
|
||||
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
PyObject* UIGridPointStateVector_to_PyList(const std::vector<UIGridPointState>& vec) {
|
||||
PyObject* list = PyList_New(vec.size());
|
||||
if (!list) return PyErr_NoMemory();
|
||||
|
||||
for (size_t i = 0; i < vec.size(); ++i) {
|
||||
PyObject* obj = UIGridPointState_to_PyObject(vec[i]);
|
||||
if (!obj) { // Cleanup on failure
|
||||
Py_DECREF(list);
|
||||
return NULL;
|
||||
}
|
||||
PyList_SET_ITEM(list, i, obj); // This steals a reference to obj
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) {
|
||||
if (reinterpret_cast<intptr_t>(closure) == 0) {
|
||||
return sfVector2f_to_PyObject(self->data->position);
|
||||
|
|
@ -444,9 +413,71 @@ int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closur
|
|||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::get_gridstate(PyUIEntityObject* self, void* closure) {
|
||||
// Assuming a function to convert std::vector<UIGridPointState> to PyObject* list
|
||||
return UIGridPointStateVector_to_PyList(self->data->gridstate);
|
||||
// #294: perspective_map property. Returns a live DiscreteMap reference
|
||||
// (not a snapshot); lazy-allocates on first access when a grid is set.
|
||||
PyObject* UIEntity::get_perspective_map(PyUIEntityObject* self, void* closure) {
|
||||
auto& entity = self->data;
|
||||
if (!entity->grid) Py_RETURN_NONE;
|
||||
if (!entity->perspective_map) {
|
||||
entity->perspective_map = std::make_shared<DiscreteMap>(
|
||||
entity->grid->grid_w, entity->grid->grid_h, 0);
|
||||
}
|
||||
|
||||
// Wrap in PyDiscreteMapObject sharing the same shared_ptr.
|
||||
auto type = &mcrfpydef::PyDiscreteMapType;
|
||||
auto obj = (PyDiscreteMapObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return NULL;
|
||||
new (&obj->data) std::shared_ptr<DiscreteMap>(entity->perspective_map);
|
||||
obj->values = obj->data->data();
|
||||
obj->w = obj->data->width();
|
||||
obj->h = obj->data->height();
|
||||
obj->enum_type = PyPerspective::perspective_enum_class;
|
||||
if (obj->enum_type) Py_INCREF(obj->enum_type);
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
// #294: Assign a DiscreteMap as the entity's perspective. The incoming map
|
||||
// must match the grid's current dimensions; otherwise ValueError is raised.
|
||||
// Assigning None clears the perspective (it will be lazy-reallocated on next
|
||||
// access or updateVisibility()).
|
||||
int UIEntity::set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
auto& entity = self->data;
|
||||
|
||||
if (value == NULL || value == Py_None) {
|
||||
entity->perspective_map.reset();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyDiscreteMapType)) {
|
||||
PyErr_SetString(PyExc_TypeError,
|
||||
"perspective_map must be a DiscreteMap or None");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!entity->grid) {
|
||||
PyErr_SetString(PyExc_ValueError,
|
||||
"Cannot assign perspective_map: entity has no grid");
|
||||
return -1;
|
||||
}
|
||||
|
||||
auto* incoming = (PyDiscreteMapObject*)value;
|
||||
if (!incoming->data) {
|
||||
PyErr_SetString(PyExc_ValueError,
|
||||
"perspective_map DiscreteMap is not initialized");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (incoming->data->width() != entity->grid->grid_w ||
|
||||
incoming->data->height() != entity->grid->grid_h) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"DiscreteMap size (%d, %d) does not match grid size (%d, %d)",
|
||||
incoming->data->width(), incoming->data->height(),
|
||||
entity->grid->grid_w, entity->grid->grid_h);
|
||||
return -1;
|
||||
}
|
||||
|
||||
entity->perspective_map = incoming->data; // share ownership
|
||||
return 0;
|
||||
}
|
||||
|
||||
int UIEntity::set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
|
|
@ -754,7 +785,7 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
|
|||
}
|
||||
self->data->grid.reset();
|
||||
|
||||
// Release identity strong ref — entity left grid
|
||||
// Release identity strong ref -- entity left grid
|
||||
self->data->releasePyIdentity();
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -796,9 +827,11 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
|
|||
new_grid->entities->push_back(self->data);
|
||||
self->data->grid = new_grid;
|
||||
new_grid->spatial_hash.insert(self->data); // #274
|
||||
|
||||
// Resize gridstate to match new grid dimensions
|
||||
self->data->ensureGridstate();
|
||||
// #294: perspective_map is lazy -- the next updateVisibility() call
|
||||
// (or first `entity.perspective_map` access) allocates sized to the
|
||||
// new grid. We deliberately do NOT preserve or clear the old map:
|
||||
// game code that wants per-grid memory should save/restore via
|
||||
// to_bytes/from_bytes and assign before calling updateVisibility.
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
|
@ -1023,7 +1056,7 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
|
|||
// Clear the grid reference
|
||||
self->data->grid.reset();
|
||||
|
||||
// Release identity strong ref — entity is no longer in a grid
|
||||
// Release identity strong ref -- entity is no longer in a grid
|
||||
self->data->releasePyIdentity();
|
||||
}
|
||||
|
||||
|
|
@ -1246,17 +1279,20 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
|
|||
|
||||
PyMethodDef UIEntity::methods[] = {
|
||||
{"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS,
|
||||
"at(x, y) or at(pos) -> GridPointState\n\n"
|
||||
"Get the grid point state at the specified position.\n\n"
|
||||
"at(x, y) or at(pos) -> GridPoint | None\n\n"
|
||||
"Return the GridPoint at (x, y) if currently VISIBLE to this entity's\n"
|
||||
"perspective_map, otherwise None. Equivalent to:\n"
|
||||
" grid.at(x, y) if perspective_map[x, y] == Perspective.VISIBLE else None\n\n"
|
||||
"To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y]\n"
|
||||
"directly and use grid.at(x, y) for cell data.\n\n"
|
||||
"Args:\n"
|
||||
" x, y: Grid coordinates as two integers, OR\n"
|
||||
" pos: Grid coordinates as tuple, list, or Vector\n\n"
|
||||
"Returns:\n"
|
||||
" GridPointState for the entity's view of that grid cell.\n\n"
|
||||
"Example:\n"
|
||||
" state = entity.at(5, 3)\n"
|
||||
" state = entity.at((5, 3))\n"
|
||||
" state = entity.at(pos=(5, 3))"},
|
||||
" point = entity.at(5, 3)\n"
|
||||
" if point is not None and point.walkable: ...\n"
|
||||
" point = entity.at((5, 3))\n"
|
||||
" point = entity.at(pos=(5, 3))"},
|
||||
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
|
||||
{"die", (PyCFunction)UIEntity::die, METH_NOARGS,
|
||||
"Remove this entity from its grid.\n\n"
|
||||
|
|
@ -1290,8 +1326,9 @@ PyMethodDef UIEntity::methods[] = {
|
|||
"update_visibility() -> None\n\n"
|
||||
"Update entity's visibility state based on current FOV.\n\n"
|
||||
"Recomputes which cells are visible from the entity's position and updates\n"
|
||||
"the entity's gridstate to track explored areas. This is called automatically\n"
|
||||
"when the entity moves if it has a grid with perspective set."},
|
||||
"the entity's perspective_map (see entity.perspective_map and mcrfpy.Perspective).\n"
|
||||
"This is called automatically when the entity moves if it has a grid with\n"
|
||||
"perspective set."},
|
||||
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
|
||||
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
|
||||
"Get list of other entities visible from this entity's position.\n\n"
|
||||
|
|
@ -1618,17 +1655,20 @@ PyMethodDef UIEntity_all_methods[] = {
|
|||
"Use list target with loop=True for repeating sprite frame animations.")
|
||||
)},
|
||||
{"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS,
|
||||
"at(x, y) or at(pos) -> GridPointState\n\n"
|
||||
"Get the grid point state at the specified position.\n\n"
|
||||
"at(x, y) or at(pos) -> GridPoint | None\n\n"
|
||||
"Return the GridPoint at (x, y) if currently VISIBLE to this entity's\n"
|
||||
"perspective_map, otherwise None. Equivalent to:\n"
|
||||
" grid.at(x, y) if perspective_map[x, y] == Perspective.VISIBLE else None\n\n"
|
||||
"To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y]\n"
|
||||
"directly and use grid.at(x, y) for cell data.\n\n"
|
||||
"Args:\n"
|
||||
" x, y: Grid coordinates as two integers, OR\n"
|
||||
" pos: Grid coordinates as tuple, list, or Vector\n\n"
|
||||
"Returns:\n"
|
||||
" GridPointState for the entity's view of that grid cell.\n\n"
|
||||
"Example:\n"
|
||||
" state = entity.at(5, 3)\n"
|
||||
" state = entity.at((5, 3))\n"
|
||||
" state = entity.at(pos=(5, 3))"},
|
||||
" point = entity.at(5, 3)\n"
|
||||
" if point is not None and point.walkable: ...\n"
|
||||
" point = entity.at((5, 3))\n"
|
||||
" point = entity.at(pos=(5, 3))"},
|
||||
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
|
||||
{"die", (PyCFunction)UIEntity::die, METH_NOARGS,
|
||||
"Remove this entity from its grid.\n\n"
|
||||
|
|
@ -1662,8 +1702,9 @@ PyMethodDef UIEntity_all_methods[] = {
|
|||
"update_visibility() -> None\n\n"
|
||||
"Update entity's visibility state based on current FOV.\n\n"
|
||||
"Recomputes which cells are visible from the entity's position and updates\n"
|
||||
"the entity's gridstate to track explored areas. This is called automatically\n"
|
||||
"when the entity moves if it has a grid with perspective set."},
|
||||
"the entity's perspective_map (see entity.perspective_map and mcrfpy.Perspective).\n"
|
||||
"This is called automatically when the entity moves if it has a grid with\n"
|
||||
"perspective set."},
|
||||
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
|
||||
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
|
||||
"Get list of other entities visible from this entity's position.\n\n"
|
||||
|
|
@ -1726,7 +1767,16 @@ PyGetSetDef UIEntity::getsetters[] = {
|
|||
{"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position,
|
||||
"Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.", (void*)0},
|
||||
|
||||
{"gridstate", (getter)UIEntity::get_gridstate, NULL, "Grid point states for the entity", NULL},
|
||||
{"perspective_map", (getter)UIEntity::get_perspective_map, (setter)UIEntity::set_perspective_map,
|
||||
"Per-entity FOV memory (DiscreteMap, read-write). 3-state values per cell: "
|
||||
"0 = unknown (never seen), 1 = discovered (seen before, not currently visible), "
|
||||
"2 = visible (in current FOV). Use mcrfpy.Perspective enum for clarity. "
|
||||
"Lazy-allocated on first access once the entity has a grid; returns None otherwise. "
|
||||
"The returned DiscreteMap is a live reference -- mutations are visible to subsequent "
|
||||
"updateVisibility() calls. Assigning a DiscreteMap replaces the entity's memory; "
|
||||
"the new map's size must match the grid's size or ValueError is raised. "
|
||||
"Assign None to clear (will be lazy-reallocated on next access).",
|
||||
NULL},
|
||||
{"grid", (getter)UIEntity::get_grid, (setter)UIEntity::set_grid,
|
||||
"Grid this entity belongs to. "
|
||||
"Get: Returns the Grid or None. "
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
#include "UIBase.h"
|
||||
#include "UISprite.h"
|
||||
#include "EntityBehavior.h"
|
||||
#include "DiscreteMap.h"
|
||||
#include <memory>
|
||||
|
||||
class UIGrid;
|
||||
|
||||
|
|
@ -57,8 +59,6 @@ class UIGrid;
|
|||
// helper methods with no namespace requirement
|
||||
PyObject* sfVector2f_to_PyObject(sf::Vector2f vector);
|
||||
sf::Vector2f PyObject_to_sfVector2f(PyObject* obj);
|
||||
PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state);
|
||||
PyObject* UIGridPointStateVector_to_PyList(const std::vector<UIGridPointState>& vec);
|
||||
|
||||
class UIEntity : public std::enable_shared_from_this<UIEntity>
|
||||
{
|
||||
|
|
@ -66,7 +66,12 @@ public:
|
|||
uint64_t serial_number = 0; // For Python object cache
|
||||
PyObject* pyobject = nullptr; // Strong ref: preserves Python subclass identity while in grid
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::vector<UIGridPointState> gridstate;
|
||||
// Per-entity perspective memory (#294): 3-state DiscreteMap --
|
||||
// 0 = unknown, 1 = discovered, 2 = visible. Lazily allocated on first
|
||||
// access to entity.perspective_map (when a grid is set) and whenever
|
||||
// updateVisibility() runs with a grid whose dimensions differ from the
|
||||
// current map. See PyPerspective for the Python-side enum.
|
||||
std::shared_ptr<DiscreteMap> perspective_map;
|
||||
UISprite sprite;
|
||||
sf::Vector2f position; //(x,y) in grid coordinates; float for animation
|
||||
sf::Vector2i cell_position{0, 0}; // #295: integer logical position (decoupled from float position)
|
||||
|
|
@ -122,8 +127,7 @@ public:
|
|||
}
|
||||
|
||||
// Visibility methods
|
||||
void ensureGridstate(); // Resize gridstate to match current grid dimensions
|
||||
void updateVisibility(); // Update gridstate from current FOV
|
||||
void updateVisibility(); // Update perspective_map from current FOV (#294)
|
||||
|
||||
// Property system for animations
|
||||
bool setProperty(const std::string& name, float value);
|
||||
|
|
@ -151,7 +155,8 @@ public:
|
|||
|
||||
static PyObject* get_position(PyUIEntityObject* self, void* closure);
|
||||
static int set_position(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_gridstate(PyUIEntityObject* self, void* closure);
|
||||
static PyObject* get_perspective_map(PyUIEntityObject* self, void* closure);
|
||||
static int set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_spritenumber(PyUIEntityObject* self, void* closure);
|
||||
static int set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_float_member(PyUIEntityObject* self, void* closure);
|
||||
|
|
@ -259,7 +264,7 @@ namespace mcrfpydef {
|
|||
" grid_pos (Vector): Integer tile coordinates (logical game position)\n"
|
||||
" grid_x, grid_y (int): Integer tile coordinate components\n"
|
||||
" draw_pos (Vector): Fractional tile position for smooth animation\n"
|
||||
" gridstate (GridPointState): Visibility state for grid points\n"
|
||||
" perspective_map (DiscreteMap | None): 3-state per-entity FOV memory\n"
|
||||
" sprite_index (int): Current sprite index\n"
|
||||
" visible (bool): Visibility state\n"
|
||||
" opacity (float): Opacity value\n"
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t ind
|
|||
// Replace the element and set grid reference
|
||||
*it = entity->data;
|
||||
entity->data->grid = self->grid;
|
||||
entity->data->ensureGridstate();
|
||||
// #294: perspective_map is lazy; next update_visibility() sizes it.
|
||||
|
||||
// Add to spatial hash
|
||||
if (self->grid) {
|
||||
|
|
@ -494,7 +494,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
|
|||
for (const auto& entity : new_items) {
|
||||
self->data->insert(insert_pos, entity);
|
||||
entity->grid = self->grid;
|
||||
entity->ensureGridstate();
|
||||
// #294: perspective_map is lazy; sized on next update_visibility().
|
||||
if (self->grid) {
|
||||
self->grid->spatial_hash.insert(entity);
|
||||
}
|
||||
|
|
@ -521,7 +521,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
|
|||
|
||||
*cur_it = new_items[new_idx++];
|
||||
(*cur_it)->grid = self->grid;
|
||||
(*cur_it)->ensureGridstate();
|
||||
// #294: perspective_map is lazy; sized on next update_visibility().
|
||||
|
||||
if (self->grid) {
|
||||
self->grid->spatial_hash.insert(*cur_it);
|
||||
|
|
@ -582,8 +582,7 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
|
|||
}
|
||||
}
|
||||
|
||||
// Ensure gridstate matches current grid dimensions
|
||||
entity->data->ensureGridstate();
|
||||
// #294: perspective_map is lazy; sized on next update_visibility().
|
||||
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
|
@ -682,8 +681,7 @@ PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject*
|
|||
self->grid->spatial_hash.insert(entity->data);
|
||||
}
|
||||
|
||||
// Ensure gridstate matches current grid dimensions
|
||||
entity->data->ensureGridstate();
|
||||
// #294: perspective_map is lazy; sized on next update_visibility().
|
||||
|
||||
Py_DECREF(entity); // Release the reference we held during validation
|
||||
}
|
||||
|
|
@ -737,7 +735,7 @@ PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* ar
|
|||
if (entity->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number);
|
||||
if (cached) {
|
||||
// Release identity ref — entity is leaving the grid
|
||||
// Release identity ref -- entity is leaving the grid
|
||||
// The caller now holds a strong ref via 'cached'
|
||||
entity->releasePyIdentity();
|
||||
return cached;
|
||||
|
|
@ -810,8 +808,7 @@ PyObject* UIEntityCollection::insert(PyUIEntityCollectionObject* self, PyObject*
|
|||
self->grid->spatial_hash.insert(entity->data);
|
||||
}
|
||||
|
||||
// Ensure gridstate matches current grid dimensions
|
||||
entity->data->ensureGridstate();
|
||||
// #294: perspective_map is lazy; sized on next update_visibility().
|
||||
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,8 +289,11 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
sf::RectangleShape overlay;
|
||||
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
|
||||
|
||||
if (entity) {
|
||||
// Valid entity - use its gridstate for visibility
|
||||
if (entity && entity->perspective_map) {
|
||||
// #294: perspective_map values -- 0=unknown, 1=discovered, 2=visible.
|
||||
const uint8_t* pm = entity->perspective_map->data();
|
||||
const int pm_w = entity->perspective_map->width();
|
||||
const int pm_h = entity->perspective_map->height();
|
||||
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
|
||||
x < x_limit;
|
||||
x+=1)
|
||||
|
|
@ -301,30 +304,23 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
{
|
||||
// Skip out-of-bounds cells
|
||||
if (x < 0 || x >= grid_w || y < 0 || y >= grid_h) continue;
|
||||
|
||||
if (x >= pm_w || y >= pm_h) continue;
|
||||
|
||||
auto pixel_pos = sf::Vector2f(
|
||||
(x*cell_width - left_spritepixels) * zoom,
|
||||
(y*cell_height - top_spritepixels) * zoom );
|
||||
|
||||
// Get visibility state from entity's perspective
|
||||
int idx = y * grid_w + 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));
|
||||
activeTexture->draw(overlay);
|
||||
} else if (!state.visible) {
|
||||
// Discovered but not currently visible - dark gray
|
||||
overlay.setFillColor(sf::Color(32, 32, 40, 192));
|
||||
activeTexture->draw(overlay);
|
||||
}
|
||||
// If visible and discovered, no overlay (fully visible)
|
||||
uint8_t state = pm[y * pm_w + x];
|
||||
overlay.setPosition(pixel_pos);
|
||||
|
||||
if (state == 0) {
|
||||
overlay.setFillColor(sf::Color(0, 0, 0, 255));
|
||||
activeTexture->draw(overlay);
|
||||
} else if (state == 1) {
|
||||
overlay.setFillColor(sf::Color(32, 32, 40, 192));
|
||||
activeTexture->draw(overlay);
|
||||
}
|
||||
// state == 2: visible -- no overlay
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -157,94 +157,8 @@ PyObject* UIGridPoint::repr(PyUIGridPointObject* self) {
|
|||
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
||||
}
|
||||
|
||||
// Helper to safely get the GridPointState data from coordinates
|
||||
static UIGridPointState* getGridPointStateData(PyUIGridPointStateObject* self) {
|
||||
if (!self->entity || !self->entity->grid) return nullptr;
|
||||
int idx = self->y * self->entity->grid->grid_w + self->x;
|
||||
if (idx < 0 || idx >= static_cast<int>(self->entity->gridstate.size())) return nullptr;
|
||||
return &self->entity->gridstate[idx];
|
||||
}
|
||||
|
||||
PyObject* UIGridPointState::get_bool_member(PyUIGridPointStateObject* self, void* closure) {
|
||||
auto* data = getGridPointStateData(self);
|
||||
if (!data) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "GridPointState data is no longer valid");
|
||||
return NULL;
|
||||
}
|
||||
if (reinterpret_cast<intptr_t>(closure) == 0) { // visible
|
||||
return PyBool_FromLong(data->visible);
|
||||
} else { // discovered
|
||||
return PyBool_FromLong(data->discovered);
|
||||
}
|
||||
}
|
||||
|
||||
int UIGridPointState::set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure) {
|
||||
auto* data = getGridPointStateData(self);
|
||||
if (!data) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "GridPointState data is no longer valid");
|
||||
return -1;
|
||||
}
|
||||
if (!PyBool_Check(value)) {
|
||||
PyErr_SetString(PyExc_TypeError, "Value must be a boolean");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int truthValue = PyObject_IsTrue(value);
|
||||
if (truthValue < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (reinterpret_cast<intptr_t>(closure) == 0) { // visible
|
||||
data->visible = truthValue;
|
||||
} else { // discovered
|
||||
data->discovered = truthValue;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// #16 - Get GridPoint at this position (None if not discovered)
|
||||
PyObject* UIGridPointState::get_point(PyUIGridPointStateObject* self, void* closure) {
|
||||
// Return None if entity hasn't discovered this cell
|
||||
auto* data = getGridPointStateData(self);
|
||||
if (!data || !data->discovered) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
if (!self->grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "GridPointState has no parent grid");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Return the GridPoint at this position (use type directly since it's internal-only)
|
||||
auto type = &mcrfpydef::PyUIGridPointType;
|
||||
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return NULL;
|
||||
|
||||
obj->grid = self->grid;
|
||||
obj->x = self->x;
|
||||
obj->y = self->y;
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
PyGetSetDef UIGridPointState::getsetters[] = {
|
||||
{"visible", (getter)UIGridPointState::get_bool_member, (setter)UIGridPointState::set_bool_member, "Is the GridPointState visible", (void*)0},
|
||||
{"discovered", (getter)UIGridPointState::get_bool_member, (setter)UIGridPointState::set_bool_member, "Has the GridPointState been discovered", (void*)1},
|
||||
{"point", (getter)UIGridPointState::get_point, NULL, "GridPoint at this position (None if not discovered)", NULL},
|
||||
{NULL} /* Sentinel */
|
||||
};
|
||||
|
||||
PyObject* UIGridPointState::repr(PyUIGridPointStateObject* self) {
|
||||
std::ostringstream ss;
|
||||
auto* gps = getGridPointStateData(self);
|
||||
if (!gps) ss << "<GridPointState (invalid internal object)>";
|
||||
else {
|
||||
ss << "<GridPointState (visible=" << (gps->visible ? "True" : "False") << ", discovered=" << (gps->discovered ? "True" : "False") <<
|
||||
")>";
|
||||
}
|
||||
std::string repr_str = ss.str();
|
||||
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
||||
}
|
||||
// UIGridPointState removed in #294. Per-entity visibility memory now lives on
|
||||
// UIEntity::perspective_map as a DiscreteMap. See mcrfpy.Perspective enum.
|
||||
|
||||
// #150 - Dynamic attribute access for named layers
|
||||
PyObject* UIGridPoint::getattro(PyUIGridPointObject* self, PyObject* name) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class UIGrid;
|
|||
class GridData;
|
||||
class UIEntity;
|
||||
class UIGridPoint;
|
||||
class UIGridPointState;
|
||||
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
|
|
@ -27,13 +26,6 @@ typedef struct {
|
|||
int x, y; // Grid coordinates - compute data pointer on access
|
||||
} PyUIGridPointObject;
|
||||
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::shared_ptr<UIEntity> entity;
|
||||
int x, y; // Position in grid - compute state on access
|
||||
} PyUIGridPointStateObject;
|
||||
|
||||
// UIGridPoint - grid cell data for pathfinding and layer access
|
||||
// #150 - Layer-related properties (color, tilesprite, etc.) removed; now handled by layers
|
||||
class UIGridPoint
|
||||
|
|
@ -61,20 +53,9 @@ public:
|
|||
static int setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value);
|
||||
};
|
||||
|
||||
// UIGridPointState - entity-specific info for each cell
|
||||
class UIGridPointState
|
||||
{
|
||||
public:
|
||||
bool visible, discovered;
|
||||
|
||||
static PyObject* get_bool_member(PyUIGridPointStateObject* self, void* closure);
|
||||
static int set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure);
|
||||
static PyGetSetDef getsetters[];
|
||||
static PyObject* repr(PyUIGridPointStateObject* self);
|
||||
|
||||
// #16 - point property: access to GridPoint (None if not discovered)
|
||||
static PyObject* get_point(PyUIGridPointStateObject* self, void* closure);
|
||||
};
|
||||
// UIGridPointState / PyUIGridPointStateType removed in #294.
|
||||
// Per-entity visibility memory now lives on UIEntity::perspective_map as a
|
||||
// DiscreteMap (3-state: 0=unknown, 1=discovered, 2=visible). See mcrfpy.Perspective.
|
||||
|
||||
namespace mcrfpydef {
|
||||
// #189 - Use inline instead of static to ensure single instance across translation units
|
||||
|
|
@ -89,7 +70,7 @@ namespace mcrfpydef {
|
|||
.tp_setattro = (setattrofunc)UIGridPoint::setattro,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR(
|
||||
"GridPoint — a single cell in a Grid.\n\n"
|
||||
"GridPoint -- a single cell in a Grid.\n\n"
|
||||
"Obtained via grid.at(x, y). Cannot be constructed directly.\n\n"
|
||||
"Properties:\n"
|
||||
" walkable (bool): Whether entities can traverse this cell.\n"
|
||||
|
|
@ -102,24 +83,4 @@ namespace mcrfpydef {
|
|||
//.tp_init = (initproc)PyUIGridPoint_init, // TODO Define the init function
|
||||
.tp_new = NULL, // Prevent instantiation from Python - Issue #12
|
||||
};
|
||||
|
||||
// #189 - Use inline instead of static to ensure single instance across translation units
|
||||
inline PyTypeObject PyUIGridPointStateType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.GridPointState",
|
||||
.tp_basicsize = sizeof(PyUIGridPointStateObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_repr = (reprfunc)UIGridPointState::repr,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR(
|
||||
"GridPointState — per-entity visibility state for a grid cell.\n\n"
|
||||
"Obtained via entity.gridstate. Cannot be constructed directly.\n\n"
|
||||
"Properties:\n"
|
||||
" visible (bool): Whether this cell is currently in the entity's FOV.\n"
|
||||
" discovered (bool): Whether this cell has ever been seen.\n"
|
||||
" point (GridPoint, read-only): The underlying GridPoint, or None.\n"
|
||||
),
|
||||
.tp_getset = UIGridPointState::getsetters,
|
||||
.tp_new = NULL, // Prevent instantiation from Python - Issue #12
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ void UIGridView::center_camera(float tile_x, float tile_y)
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// Render — adapted from UIGrid::render()
|
||||
// Render -- adapted from UIGrid::render()
|
||||
// =========================================================================
|
||||
void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
||||
{
|
||||
|
|
@ -225,25 +225,28 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
sf::RectangleShape overlay;
|
||||
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
|
||||
|
||||
if (entity) {
|
||||
if (entity && entity->perspective_map) {
|
||||
// #294: perspective_map values: 0=unknown, 1=discovered, 2=visible.
|
||||
const uint8_t* pm = entity->perspective_map->data();
|
||||
const int pm_w = entity->perspective_map->width();
|
||||
const int pm_h = entity->perspective_map->height();
|
||||
for (int x = std::max(0, (int)(left_edge - 1)); x < x_limit; x++) {
|
||||
for (int y = std::max(0, (int)(top_edge - 1)); y < y_limit; y++) {
|
||||
if (x < 0 || x >= grid_data->grid_w || y < 0 || y >= grid_data->grid_h) continue;
|
||||
if (x >= pm_w || y >= pm_h) continue;
|
||||
auto pixel_pos = sf::Vector2f(
|
||||
(x*cell_width - left_spritepixels) * zoom,
|
||||
(y*cell_height - top_spritepixels) * zoom);
|
||||
int idx = y * grid_data->grid_w + x;
|
||||
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
|
||||
const auto& state = entity->gridstate[idx];
|
||||
overlay.setPosition(pixel_pos);
|
||||
if (!state.discovered) {
|
||||
overlay.setFillColor(sf::Color(0, 0, 0, 255));
|
||||
activeTexture->draw(overlay);
|
||||
} else if (!state.visible) {
|
||||
overlay.setFillColor(sf::Color(32, 32, 40, 192));
|
||||
activeTexture->draw(overlay);
|
||||
}
|
||||
uint8_t state = pm[y * pm_w + x];
|
||||
overlay.setPosition(pixel_pos);
|
||||
if (state == 0) {
|
||||
overlay.setFillColor(sf::Color(0, 0, 0, 255));
|
||||
activeTexture->draw(overlay);
|
||||
} else if (state == 1) {
|
||||
overlay.setFillColor(sf::Color(32, 32, 40, 192));
|
||||
activeTexture->draw(overlay);
|
||||
}
|
||||
// state == 2: visible -- no overlay
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
587
stubs/mcrfpy.pyi
587
stubs/mcrfpy.pyi
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
|||
"""Regression test: GridPointState references must not dangle after grid resize.
|
||||
|
||||
Issue #265: GridPointState objects held Python references to internal vectors
|
||||
that could be invalidated when the underlying grid data was reallocated.
|
||||
|
||||
Fix: GridPointState now copies data or holds safe references that survive
|
||||
grid modifications.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
PASS = True
|
||||
|
||||
def check(name, condition):
|
||||
global PASS
|
||||
if not condition:
|
||||
print(f"FAIL: {name}")
|
||||
PASS = False
|
||||
else:
|
||||
print(f" ok: {name}")
|
||||
|
||||
# Test 1: Access GridPointState after entity transfer
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
||||
entity.update_visibility()
|
||||
|
||||
state = entity.at(3, 3)
|
||||
check("GridPointState accessible", state is not None)
|
||||
|
||||
# Transfer entity to a different grid (invalidates old gridstate)
|
||||
grid2 = mcrfpy.Grid(grid_size=(20, 20))
|
||||
entity.grid = grid2
|
||||
entity.update_visibility()
|
||||
|
||||
# Old state reference should not crash
|
||||
# (In the buggy version, accessing state after transfer would read freed memory)
|
||||
try:
|
||||
# Access the new gridstate
|
||||
new_state = entity.at(15, 15)
|
||||
check("GridPointState valid after transfer", new_state is not None)
|
||||
except Exception as e:
|
||||
check(f"GridPointState valid after transfer (exception: {e})", False)
|
||||
|
||||
# Test 2: Multiple entities with GridPointState references
|
||||
entities = []
|
||||
for i in range(5):
|
||||
e = mcrfpy.Entity(grid_pos=(i, i), grid=grid)
|
||||
entities.append(e)
|
||||
|
||||
for e in entities:
|
||||
e.update_visibility()
|
||||
|
||||
states = [e.at(0, 0) for e in entities]
|
||||
check("Multiple GridPointState refs created", len(states) == 5)
|
||||
|
||||
# Remove all entities (should not crash)
|
||||
for e in entities:
|
||||
e.grid = None
|
||||
|
||||
check("Entities removed safely", len(grid.entities) == 0)
|
||||
|
||||
# Test 3: GridPoint reference stability
|
||||
gp = grid.at(5, 5)
|
||||
check("GridPoint accessible", gp is not None)
|
||||
check("GridPoint walkable", gp.walkable == True or gp.walkable == False)
|
||||
|
||||
if PASS:
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
104
tests/regression/issue_294_perspective_map_test.py
Normal file
104
tests/regression/issue_294_perspective_map_test.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""
|
||||
Regression for #294: entity.perspective_map replaces the old gridstate array.
|
||||
|
||||
Verifies:
|
||||
- visible subset discovered invariant holds after every updateVisibility()
|
||||
(no cell is VISIBLE without also being, at minimum, DISCOVERED when the
|
||||
entity next looks away)
|
||||
- Grid-transition round-trip: save perspective on grid A, restore on
|
||||
same-sized grid B, FOV continues correctly without wiping prior knowledge.
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_invariant(pm):
|
||||
"""Every value in the map is 0, 1, or 2 (structural visible<=discovered)."""
|
||||
for y in range(pm.size[1]):
|
||||
for x in range(pm.size[0]):
|
||||
v = int(pm[x, y])
|
||||
if v not in (0, 1, 2):
|
||||
fail(f"pm[{x},{y}] = {v}, must be 0/1/2 (structural invariant)")
|
||||
|
||||
|
||||
def main():
|
||||
scene = mcrfpy.Scene("issue_294_regression")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
# Scenario 1: invariant after repeated movement + updateVisibility.
|
||||
grid_a = mcrfpy.Grid(grid_size=(15, 10))
|
||||
scene.children.append(grid_a)
|
||||
e = mcrfpy.Entity(grid_pos=(7, 5))
|
||||
grid_a.entities.append(e)
|
||||
|
||||
for target in [(7, 5), (3, 3), (12, 8), (1, 1), (14, 9), (7, 5)]:
|
||||
e.grid_pos = target
|
||||
e.update_visibility()
|
||||
check_invariant(e.perspective_map)
|
||||
|
||||
# Scenario 2: a previously-seen cell that moves out of FOV is DISCOVERED
|
||||
# (not UNKNOWN) — the hallmark of the 3-state model.
|
||||
e.grid_pos = (7, 5)
|
||||
e.update_visibility()
|
||||
pm = e.perspective_map
|
||||
# Collect cells VISIBLE at origin
|
||||
origin_visible = [(x, y) for y in range(10) for x in range(15)
|
||||
if int(pm[x, y]) == 2]
|
||||
if not origin_visible:
|
||||
fail("expected at least one VISIBLE cell at origin position")
|
||||
|
||||
# Move far away so fewer of the origin cells are in FOV
|
||||
e.grid_pos = (14, 9)
|
||||
e.update_visibility()
|
||||
for (x, y) in origin_visible:
|
||||
v = int(pm[x, y])
|
||||
if v == 0:
|
||||
fail(f"cell ({x},{y}) was VISIBLE then moved out of FOV — "
|
||||
f"should be DISCOVERED (1), not UNKNOWN (0)")
|
||||
|
||||
# Scenario 3: serialize on grid A, restore on grid B (same size), FOV continues.
|
||||
e.grid_pos = (7, 5)
|
||||
e.update_visibility()
|
||||
saved_bytes = e.perspective_map.to_bytes()
|
||||
|
||||
grid_b = mcrfpy.Grid(grid_size=(15, 10))
|
||||
scene.children.append(grid_b)
|
||||
e.grid = grid_b # remove from A, add to B
|
||||
e.grid_pos = (7, 5)
|
||||
|
||||
# On the new grid, perspective_map lazy-allocates fresh (all zeros).
|
||||
# Assign the saved bytes to restore.
|
||||
restored = mcrfpy.DiscreteMap.from_bytes(saved_bytes, (15, 10))
|
||||
e.perspective_map = restored
|
||||
|
||||
pm_b = e.perspective_map
|
||||
# Prior DISCOVERED/VISIBLE cells should still be there.
|
||||
seen_any = False
|
||||
for y in range(10):
|
||||
for x in range(15):
|
||||
if int(pm_b[x, y]) >= 1:
|
||||
seen_any = True
|
||||
break
|
||||
if seen_any:
|
||||
break
|
||||
if not seen_any:
|
||||
fail("after restore, at least one cell should be DISCOVERED (>=1)")
|
||||
|
||||
# update_visibility on grid B should promote cells in new FOV to VISIBLE
|
||||
# but preserve DISCOVERED cells elsewhere.
|
||||
e.update_visibility()
|
||||
check_invariant(pm_b)
|
||||
if int(pm_b[7, 5]) != 2:
|
||||
fail("entity's cell on grid B should be VISIBLE after update_visibility")
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
84
tests/unit/perspective_enum_test.py
Normal file
84
tests/unit/perspective_enum_test.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Tests for mcrfpy.Perspective enum and entity.at() new semantics (#294).
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
# Enum members
|
||||
p = mcrfpy.Perspective
|
||||
if int(p.UNKNOWN) != 0:
|
||||
fail("Perspective.UNKNOWN should be 0")
|
||||
if int(p.DISCOVERED) != 1:
|
||||
fail("Perspective.DISCOVERED should be 1")
|
||||
if int(p.VISIBLE) != 2:
|
||||
fail("Perspective.VISIBLE should be 2")
|
||||
|
||||
# IntEnum: equality with ints both ways
|
||||
if p.VISIBLE != 2 or 2 != p.VISIBLE:
|
||||
fail("IntEnum equality failed")
|
||||
|
||||
# Repr uses the enum name
|
||||
if repr(p.DISCOVERED) != "Perspective.DISCOVERED":
|
||||
fail(f"unexpected repr: {repr(p.DISCOVERED)}")
|
||||
|
||||
# entity.at() semantics: VISIBLE -> GridPoint, else None.
|
||||
scene = mcrfpy.Scene("perspective_enum")
|
||||
mcrfpy.current_scene = scene
|
||||
grid = mcrfpy.Grid(grid_size=(7, 5))
|
||||
scene.children.append(grid)
|
||||
e = mcrfpy.Entity(grid_pos=(3, 2))
|
||||
grid.entities.append(e)
|
||||
|
||||
# Without update_visibility, perspective_map is all-zero.
|
||||
if e.at(3, 2) is not None:
|
||||
fail("before update_visibility, at() should return None (cells are UNKNOWN)")
|
||||
|
||||
e.update_visibility()
|
||||
p_here = e.at(3, 2)
|
||||
if p_here is None:
|
||||
fail("after update_visibility, at(own cell) should return a GridPoint")
|
||||
if not hasattr(p_here, "walkable"):
|
||||
fail("at() return should be a GridPoint (has .walkable)")
|
||||
|
||||
# Manually promote a cell to DISCOVERED only, confirm at() still returns None.
|
||||
pm = e.perspective_map
|
||||
# Find a cell currently UNKNOWN, set to DISCOVERED, confirm at() -> None.
|
||||
for y in range(5):
|
||||
for x in range(7):
|
||||
if int(pm[x, y]) == 0:
|
||||
pm[x, y] = 1 # DISCOVERED
|
||||
if e.at(x, y) is not None:
|
||||
fail(f"DISCOVERED-only cell ({x},{y}): at() should return None")
|
||||
pm[x, y] = 2 # VISIBLE
|
||||
if e.at(x, y) is None:
|
||||
fail(f"VISIBLE cell ({x},{y}): at() should return a GridPoint")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Accept enum as Perspective value assignment via subscript
|
||||
pm[0, 0] = mcrfpy.Perspective.VISIBLE
|
||||
if int(pm[0, 0]) != 2:
|
||||
fail("DiscreteMap subscript should accept Perspective enum member")
|
||||
|
||||
# Out-of-bounds at() raises IndexError
|
||||
try:
|
||||
e.at(100, 100)
|
||||
fail("out-of-bounds at() should raise IndexError")
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
87
tests/unit/perspective_map_serialization_test.py
Normal file
87
tests/unit/perspective_map_serialization_test.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
Tests for entity.perspective_map serialization round-trip and size validation (#294).
|
||||
|
||||
Covers:
|
||||
- to_bytes() / from_bytes() preserves all three perspective states
|
||||
- Assigning a size-mismatched DiscreteMap raises ValueError
|
||||
- Assigning None clears (and getter lazy-reallocates fresh)
|
||||
- Assigning a matching DiscreteMap replaces the entity's memory
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
scene = mcrfpy.Scene("serialization_test")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(8, 6))
|
||||
scene.children.append(grid)
|
||||
e = mcrfpy.Entity(grid_pos=(2, 2))
|
||||
grid.entities.append(e)
|
||||
|
||||
pm = e.perspective_map
|
||||
|
||||
# Place each of the three states at known positions.
|
||||
pm[0, 0] = 0 # UNKNOWN
|
||||
pm[1, 1] = 1 # DISCOVERED
|
||||
pm[2, 2] = 2 # VISIBLE
|
||||
|
||||
# Round-trip via bytes.
|
||||
raw = pm.to_bytes()
|
||||
if len(raw) != 8 * 6:
|
||||
fail(f"to_bytes length should be {8*6}, got {len(raw)}")
|
||||
|
||||
restored = mcrfpy.DiscreteMap.from_bytes(raw, (8, 6))
|
||||
if int(restored[0, 0]) != 0:
|
||||
fail(f"restored[0,0] should be 0, got {restored[0, 0]}")
|
||||
if int(restored[1, 1]) != 1:
|
||||
fail(f"restored[1,1] should be 1, got {restored[1, 1]}")
|
||||
if int(restored[2, 2]) != 2:
|
||||
fail(f"restored[2,2] should be 2, got {restored[2, 2]}")
|
||||
|
||||
# Assign restored back to the entity.
|
||||
e.perspective_map = restored
|
||||
after = e.perspective_map
|
||||
if int(after[1, 1]) != 1 or int(after[2, 2]) != 2:
|
||||
fail("entity.perspective_map = restored should replace memory")
|
||||
|
||||
# Size mismatch raises ValueError.
|
||||
wrong = mcrfpy.DiscreteMap((4, 4))
|
||||
try:
|
||||
e.perspective_map = wrong
|
||||
fail("size-mismatched assignment should raise ValueError")
|
||||
except ValueError as err:
|
||||
# message should mention size/grid/match
|
||||
msg = str(err).lower()
|
||||
if "size" not in msg and "match" not in msg:
|
||||
fail(f"ValueError message should mention size mismatch, got: {err}")
|
||||
|
||||
# None clears.
|
||||
e.perspective_map = None
|
||||
fresh = e.perspective_map # lazy-reallocates
|
||||
if fresh is None:
|
||||
fail("perspective_map should be lazy-reallocated after = None")
|
||||
for y in range(6):
|
||||
for x in range(8):
|
||||
if int(fresh[x, y]) != 0:
|
||||
fail(f"after clear, fresh[{x},{y}] should be 0, got {fresh[x, y]}")
|
||||
|
||||
# Type check: non-DiscreteMap raises TypeError.
|
||||
try:
|
||||
e.perspective_map = [0, 1, 2]
|
||||
fail("non-DiscreteMap assignment should raise TypeError")
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
tests/unit/perspective_map_test.py
Normal file
112
tests/unit/perspective_map_test.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Tests for Entity.perspective_map (#294).
|
||||
|
||||
Covers:
|
||||
- Lazy allocation (None when no grid; allocated on first access with a grid)
|
||||
- Identity: multiple accesses share the same underlying DiscreteMap
|
||||
- Three-state values: 0=UNKNOWN, 1=DISCOVERED, 2=VISIBLE
|
||||
- perspective_map[x, y] returns Perspective enum members
|
||||
- visible subset discovered invariant after updateVisibility
|
||||
- entity.at() returns GridPoint when VISIBLE, None otherwise
|
||||
"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
scene = mcrfpy.Scene("perspective_map_test")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
# Entity without a grid -> perspective_map is None
|
||||
orphan = mcrfpy.Entity(grid_pos=(0, 0))
|
||||
if orphan.perspective_map is not None:
|
||||
fail("entity without grid should have perspective_map == None")
|
||||
|
||||
# Attach to a grid; perspective_map lazy-allocates on first access
|
||||
grid = mcrfpy.Grid(grid_size=(12, 9))
|
||||
scene.children.append(grid)
|
||||
e = mcrfpy.Entity(grid_pos=(4, 3))
|
||||
grid.entities.append(e)
|
||||
|
||||
pm = e.perspective_map
|
||||
if pm is None:
|
||||
fail("perspective_map should be allocated once entity has a grid")
|
||||
if pm.size != (12, 9):
|
||||
fail(f"perspective_map size should match grid size, got {pm.size}")
|
||||
|
||||
# Initial state: all UNKNOWN
|
||||
for y in range(9):
|
||||
for x in range(12):
|
||||
if int(pm[x, y]) != 0:
|
||||
fail(f"initial pm[{x},{y}] should be 0, got {pm[x, y]}")
|
||||
|
||||
# Identity: a second access wraps the same shared DiscreteMap.
|
||||
# Mutating through one is visible through the other.
|
||||
pm2 = e.perspective_map
|
||||
pm2[1, 1] = 2
|
||||
if int(pm[1, 1]) != 2:
|
||||
fail("perspective_map accesses should share underlying buffer")
|
||||
|
||||
# Values returned as Perspective enum members (IntEnum).
|
||||
pm[2, 2] = 1
|
||||
val = pm[2, 2]
|
||||
if val != mcrfpy.Perspective.DISCOVERED:
|
||||
fail(f"pm[2,2] should equal Perspective.DISCOVERED, got {val!r}")
|
||||
if int(val) != 1:
|
||||
fail(f"IntEnum comparison failed: int(pm[2,2]) = {int(val)}")
|
||||
|
||||
# updateVisibility yields VISIBLE at entity's cell, UNKNOWN far away
|
||||
# (beyond FOV radius). We first wipe whatever leaked in.
|
||||
pm.fill(0)
|
||||
e.update_visibility()
|
||||
at_entity = pm[4, 3]
|
||||
if at_entity != mcrfpy.Perspective.VISIBLE:
|
||||
fail(f"entity's cell should be VISIBLE after update_visibility, got {at_entity}")
|
||||
|
||||
# Invariant: any VISIBLE cell remains at least DISCOVERED after the entity
|
||||
# moves away and we re-update.
|
||||
visible_before = set()
|
||||
for y in range(9):
|
||||
for x in range(12):
|
||||
if int(pm[x, y]) == 2:
|
||||
visible_before.add((x, y))
|
||||
|
||||
e.grid_pos = (0, 0) # move far corner
|
||||
e.update_visibility()
|
||||
|
||||
for (x, y) in visible_before:
|
||||
v = int(pm[x, y])
|
||||
if v < 1:
|
||||
fail(f"cell ({x},{y}) was VISIBLE, should now be at least DISCOVERED (>=1), got {v}")
|
||||
|
||||
# entity.at(): VISIBLE -> GridPoint; else None.
|
||||
p = e.at(0, 0)
|
||||
if p is None:
|
||||
fail("entity.at(own cell) should return GridPoint (VISIBLE)")
|
||||
if not hasattr(p, "walkable"):
|
||||
fail("entity.at(visible) should return GridPoint with .walkable")
|
||||
|
||||
# Find an UNKNOWN cell and verify at() returns None.
|
||||
found = False
|
||||
for y in range(9):
|
||||
for x in range(12):
|
||||
if int(pm[x, y]) == 0:
|
||||
if e.at(x, y) is not None:
|
||||
fail(f"entity.at({x},{y}) on UNKNOWN cell should return None")
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
# (Not fatal if no UNKNOWN cell exists — small grid + large FOV radius.)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test GridPointState.point property (#16)
|
||||
=========================================
|
||||
|
||||
Tests the GridPointState.point property that provides access to the
|
||||
GridPoint data from an entity's perspective.
|
||||
|
||||
Key behavior:
|
||||
- Returns None if the cell has not been discovered by the entity
|
||||
- Returns the GridPoint (live reference) if discovered
|
||||
- Works with the FOV/visibility system
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def run_tests():
|
||||
"""Run GridPointState.point tests"""
|
||||
print("=== GridPointState.point Tests ===\n")
|
||||
|
||||
# Test 1: Undiscovered cell returns None
|
||||
print("Test 1: Undiscovered cell returns None")
|
||||
grid = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
|
||||
|
||||
# Set up grid
|
||||
for y in range(25):
|
||||
for x in range(40):
|
||||
pt = grid.at(x, y)
|
||||
pt.walkable = True
|
||||
pt.transparent = True
|
||||
|
||||
# Create entity
|
||||
entity = mcrfpy.Entity((5, 5))
|
||||
grid.entities.append(entity)
|
||||
|
||||
# Before update_visibility, nothing is discovered
|
||||
state = entity.at((10, 10))
|
||||
assert state.point is None, f"Expected None for undiscovered cell, got {state.point}"
|
||||
print(" Undiscovered cell returns None")
|
||||
print()
|
||||
|
||||
# Test 2: Discovered cell returns GridPoint
|
||||
print("Test 2: Discovered cell returns GridPoint")
|
||||
grid.fov = mcrfpy.FOV.SHADOW
|
||||
grid.fov_radius = 8
|
||||
|
||||
entity.update_visibility()
|
||||
|
||||
# Entity's own position should be discovered
|
||||
state_own = entity.at((5, 5))
|
||||
assert state_own.discovered == True, "Entity's position should be discovered"
|
||||
assert state_own.point is not None, "Discovered cell should return GridPoint"
|
||||
print(f" Entity position: discovered={state_own.discovered}, point={state_own.point}")
|
||||
print()
|
||||
|
||||
# Test 3: GridPoint access through state
|
||||
print("Test 3: GridPoint properties accessible through state.point")
|
||||
|
||||
# Make a specific cell have known properties
|
||||
grid.at(6, 5).walkable = False
|
||||
grid.at(6, 5).transparent = True
|
||||
|
||||
state_adj = entity.at((6, 5))
|
||||
assert state_adj.point is not None, "Adjacent cell should be discovered"
|
||||
assert state_adj.point.walkable == False, f"Expected walkable=False, got {state_adj.point.walkable}"
|
||||
assert state_adj.point.transparent == True, f"Expected transparent=True, got {state_adj.point.transparent}"
|
||||
print(f" Adjacent cell (6,5): walkable={state_adj.point.walkable}, transparent={state_adj.point.transparent}")
|
||||
print()
|
||||
|
||||
# Test 4: Far cells remain undiscovered
|
||||
print("Test 4: Cells outside FOV radius remain undiscovered")
|
||||
|
||||
# Cell far from entity (outside radius 8)
|
||||
state_far = entity.at((30, 20))
|
||||
assert state_far.discovered == False, "Far cell should not be discovered"
|
||||
assert state_far.point is None, "Far cell point should be None"
|
||||
print(f" Far cell (30,20): discovered={state_far.discovered}, point={state_far.point}")
|
||||
print()
|
||||
|
||||
# Test 5: Discovered but not visible cells
|
||||
print("Test 5: Discovered but not currently visible cells")
|
||||
|
||||
# Move entity to discover new area
|
||||
entity.x = 6
|
||||
entity.y = 5
|
||||
entity.update_visibility()
|
||||
|
||||
# Move back - old visible cells should now be discovered but not visible
|
||||
entity.x = 5
|
||||
entity.y = 5
|
||||
entity.update_visibility()
|
||||
|
||||
# A cell that was visible when at (6,5) but might not be visible from (5,5)
|
||||
# Actually with radius 8, most nearby cells will still be visible
|
||||
# Let's check a cell that's on the edge
|
||||
state_edge = entity.at((12, 5)) # 7 cells away, should be visible with radius 8
|
||||
if state_edge.discovered:
|
||||
assert state_edge.point is not None, "Discovered cell should have point access"
|
||||
print(f" Edge cell (12,5): discovered={state_edge.discovered}, visible={state_edge.visible}")
|
||||
print(f" point.walkable={state_edge.point.walkable}")
|
||||
print()
|
||||
|
||||
# Test 6: GridPoint.entities through state.point
|
||||
print("Test 6: GridPoint.entities accessible through state.point")
|
||||
|
||||
# Add another entity at a visible position
|
||||
e2 = mcrfpy.Entity((7, 5))
|
||||
grid.entities.append(e2)
|
||||
|
||||
state_with_entity = entity.at((7, 5))
|
||||
assert state_with_entity.point is not None, "Cell should be discovered"
|
||||
entities_at_cell = state_with_entity.point.entities
|
||||
assert len(entities_at_cell) == 1, f"Expected 1 entity, got {len(entities_at_cell)}"
|
||||
print(f" Cell (7,5) has {len(entities_at_cell)} entity via state.point.entities")
|
||||
print()
|
||||
|
||||
# Test 7: Live reference - changes to GridPoint are reflected
|
||||
print("Test 7: state.point is a live reference")
|
||||
|
||||
# Get state before change
|
||||
state_live = entity.at((8, 5))
|
||||
original_walkable = state_live.point.walkable
|
||||
|
||||
# Change the actual GridPoint
|
||||
grid.at(8, 5).walkable = not original_walkable
|
||||
|
||||
# Check that state.point reflects the change
|
||||
new_walkable = state_live.point.walkable
|
||||
assert new_walkable != original_walkable, "state.point should reflect GridPoint changes"
|
||||
print(f" Changed walkable from {original_walkable} to {new_walkable} - reflected in state.point")
|
||||
print()
|
||||
|
||||
# Test 8: Wall blocking visibility
|
||||
print("Test 8: Walls block visibility correctly")
|
||||
|
||||
# Create a wall
|
||||
grid.at(15, 5).transparent = False
|
||||
grid.at(15, 5).walkable = False
|
||||
|
||||
entity.update_visibility()
|
||||
|
||||
# Cell behind wall should not be visible (and possibly not discovered)
|
||||
state_behind = entity.at((20, 5)) # Behind wall at x=15
|
||||
print(f" Cell behind wall (20,5): visible={state_behind.visible}, discovered={state_behind.discovered}")
|
||||
if not state_behind.discovered:
|
||||
assert state_behind.point is None, "Undiscovered cell behind wall should have point=None"
|
||||
print(" Correctly returns None for undiscovered cell behind wall")
|
||||
print()
|
||||
|
||||
print("=== All GridPointState.point Tests Passed! ===")
|
||||
return True
|
||||
|
||||
# Main execution
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
if run_tests():
|
||||
print("\nPASS")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nFAIL")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\nFAIL: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue