Phase 2: Entity data model extensions for behavior system

- Add Behavior enum (IDLE..FLEE, 11 values) and Trigger enum (DONE,
  BLOCKED, TARGET) as runtime IntEnum classes (closes #297, closes #298)
- Add entity label system: labels property (frozenset), add_label(),
  remove_label(), has_label(), constructor kwarg (closes #296)
- Add cell_pos integer logical position decoupled from float draw_pos;
  grid_pos now aliases cell_pos; SpatialHash::updateCell() for cell-based
  bucket management; FOV/visibility uses cell_position (closes #295)
- Add step callback and default_behavior properties to Entity for
  grid.step() turn management (closes #299)
- Update updateVisibility, visible_entities, ColorLayer::updatePerspective
  to use cell_position instead of float position

BREAKING: grid_pos no longer derives from float x/y position. Use
cell_pos/grid_pos for logical position, draw_pos for render position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-15 22:05:06 -04:00
commit 2f1e472245
15 changed files with 886 additions and 34 deletions

View file

@ -361,9 +361,9 @@ void ColorLayer::updatePerspective() {
if (!parent_grid) return; if (!parent_grid) return;
// Get entity position and grid's FOV settings // Get entity cell position and grid's FOV settings (#295)
int source_x = static_cast<int>(entity->position.x); int source_x = entity->cell_position.x;
int source_y = static_cast<int>(entity->position.y); int source_y = entity->cell_position.y;
int radius = parent_grid->fov_radius; int radius = parent_grid->fov_radius;
TCOD_fov_algorithm_t algorithm = parent_grid->fov_algorithm; TCOD_fov_algorithm_t algorithm = parent_grid->fov_algorithm;

View file

@ -16,6 +16,8 @@
#include "PyKey.h" #include "PyKey.h"
#include "PyMouseButton.h" #include "PyMouseButton.h"
#include "PyInputState.h" #include "PyInputState.h"
#include "PyBehavior.h"
#include "PyTrigger.h"
#include "PySound.h" #include "PySound.h"
#include "PySoundBuffer.h" #include "PySoundBuffer.h"
#include "PyMusic.h" #include "PyMusic.h"
@ -779,6 +781,18 @@ PyObject* PyInit_mcrfpy()
PyErr_Clear(); PyErr_Clear();
} }
// Add Behavior enum class for entity behavior types (#297)
PyObject* behavior_class = PyBehavior::create_enum_class(m);
if (!behavior_class) {
PyErr_Clear();
}
// Add Trigger enum class for entity step callback triggers (#298)
PyObject* trigger_class = PyTrigger::create_enum_class(m);
if (!trigger_class) {
PyErr_Clear();
}
// Add automation submodule // Add automation submodule
PyObject* automation_module = McRFPy_Automation::init_automation_module(); PyObject* automation_module = McRFPy_Automation::init_automation_module();
if (automation_module != NULL) { if (automation_module != NULL) {

116
src/PyBehavior.cpp Normal file
View file

@ -0,0 +1,116 @@
#include "PyBehavior.h"
#include <sstream>
// Static storage for cached enum class reference
PyObject* PyBehavior::behavior_enum_class = nullptr;
struct BehaviorEntry {
const char* name;
int value;
const char* description;
};
static const BehaviorEntry behavior_table[] = {
{"IDLE", 0, "No action each turn"},
{"CUSTOM", 1, "Calls step callback only, no built-in movement"},
{"NOISE4", 2, "Random movement in 4 cardinal directions"},
{"NOISE8", 3, "Random movement in 8 directions (incl. diagonals)"},
{"PATH", 4, "Follow a precomputed path to completion"},
{"WAYPOINT", 5, "Path through a sequence of waypoints in order"},
{"PATROL", 6, "Patrol waypoints back and forth (reversing at ends)"},
{"LOOP", 7, "Loop through waypoints cyclically"},
{"SLEEP", 8, "Wait for N turns, then trigger DONE"},
{"SEEK", 9, "Move toward target using Dijkstra map"},
{"FLEE", 10, "Move away from target using Dijkstra map"},
};
static const int NUM_BEHAVIOR_ENTRIES = sizeof(behavior_table) / sizeof(behavior_table[0]);
PyObject* PyBehavior::create_enum_class(PyObject* module) {
std::ostringstream code;
code << "from enum import IntEnum\n\n";
code << "class Behavior(IntEnum):\n";
code << " \"\"\"Enum representing entity behavior types for grid.step() turn management.\n";
code << " \n";
code << " Values:\n";
for (int i = 0; i < NUM_BEHAVIOR_ENTRIES; i++) {
code << " " << behavior_table[i].name << ": " << behavior_table[i].description << "\n";
}
code << " \"\"\"\n";
for (int i = 0; i < NUM_BEHAVIOR_ENTRIES; i++) {
code << " " << behavior_table[i].name << " = " << behavior_table[i].value << "\n";
}
code << R"(
def _Behavior_eq(self, other):
if isinstance(other, str):
if self.name == other:
return True
return False
return int.__eq__(int(self), other)
Behavior.__eq__ = _Behavior_eq
def _Behavior_ne(self, other):
result = type(self).__eq__(self, other)
if result is NotImplemented:
return result
return not result
Behavior.__ne__ = _Behavior_ne
Behavior.__hash__ = lambda self: hash(int(self))
Behavior.__repr__ = lambda self: f"{type(self).__name__}.{self.name}"
Behavior.__str__ = lambda self: self.name
)";
std::string code_str = code.str();
PyObject* globals = PyDict_New();
if (!globals) return NULL;
PyObject* builtins = PyEval_GetBuiltins();
PyDict_SetItemString(globals, "__builtins__", builtins);
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* behavior_class = PyDict_GetItemString(locals, "Behavior");
if (!behavior_class) {
PyErr_SetString(PyExc_RuntimeError, "Failed to create Behavior enum class");
Py_DECREF(globals);
Py_DECREF(locals);
return NULL;
}
Py_INCREF(behavior_class);
behavior_enum_class = behavior_class;
Py_INCREF(behavior_enum_class);
if (PyModule_AddObject(module, "Behavior", behavior_class) < 0) {
Py_DECREF(behavior_class);
Py_DECREF(globals);
Py_DECREF(locals);
behavior_enum_class = nullptr;
return NULL;
}
Py_DECREF(globals);
Py_DECREF(locals);
return behavior_class;
}

21
src/PyBehavior.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
#include "Common.h"
#include "Python.h"
// Module-level Behavior enum class (created at runtime using Python's IntEnum)
// Stored as a module attribute: mcrfpy.Behavior
//
// Values represent entity behavior types for grid.step() turn management.
class PyBehavior {
public:
// Create the Behavior enum class and add to module
// Returns the enum class (new reference), or NULL on error
static PyObject* create_enum_class(PyObject* module);
// Cached reference to the Behavior enum class for fast type checking
static PyObject* behavior_enum_class;
// Number of behavior types
static const int NUM_BEHAVIORS = 11;
};

108
src/PyTrigger.cpp Normal file
View file

@ -0,0 +1,108 @@
#include "PyTrigger.h"
#include <sstream>
// Static storage for cached enum class reference
PyObject* PyTrigger::trigger_enum_class = nullptr;
struct TriggerEntry {
const char* name;
int value;
const char* description;
};
static const TriggerEntry trigger_table[] = {
{"DONE", 0, "Behavior completed (path exhausted, sleep finished, etc.)"},
{"BLOCKED", 1, "Movement blocked by wall or collision"},
{"TARGET", 2, "Target entity spotted in FOV"},
};
static const int NUM_TRIGGER_ENTRIES = sizeof(trigger_table) / sizeof(trigger_table[0]);
PyObject* PyTrigger::create_enum_class(PyObject* module) {
std::ostringstream code;
code << "from enum import IntEnum\n\n";
code << "class Trigger(IntEnum):\n";
code << " \"\"\"Enum representing trigger types passed to entity step() callbacks.\n";
code << " \n";
code << " Values:\n";
for (int i = 0; i < NUM_TRIGGER_ENTRIES; i++) {
code << " " << trigger_table[i].name << ": " << trigger_table[i].description << "\n";
}
code << " \"\"\"\n";
for (int i = 0; i < NUM_TRIGGER_ENTRIES; i++) {
code << " " << trigger_table[i].name << " = " << trigger_table[i].value << "\n";
}
code << R"(
def _Trigger_eq(self, other):
if isinstance(other, str):
if self.name == other:
return True
return False
return int.__eq__(int(self), other)
Trigger.__eq__ = _Trigger_eq
def _Trigger_ne(self, other):
result = type(self).__eq__(self, other)
if result is NotImplemented:
return result
return not result
Trigger.__ne__ = _Trigger_ne
Trigger.__hash__ = lambda self: hash(int(self))
Trigger.__repr__ = lambda self: f"{type(self).__name__}.{self.name}"
Trigger.__str__ = lambda self: self.name
)";
std::string code_str = code.str();
PyObject* globals = PyDict_New();
if (!globals) return NULL;
PyObject* builtins = PyEval_GetBuiltins();
PyDict_SetItemString(globals, "__builtins__", builtins);
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* trigger_class = PyDict_GetItemString(locals, "Trigger");
if (!trigger_class) {
PyErr_SetString(PyExc_RuntimeError, "Failed to create Trigger enum class");
Py_DECREF(globals);
Py_DECREF(locals);
return NULL;
}
Py_INCREF(trigger_class);
trigger_enum_class = trigger_class;
Py_INCREF(trigger_enum_class);
if (PyModule_AddObject(module, "Trigger", trigger_class) < 0) {
Py_DECREF(trigger_class);
Py_DECREF(globals);
Py_DECREF(locals);
trigger_enum_class = nullptr;
return NULL;
}
Py_DECREF(globals);
Py_DECREF(locals);
return trigger_class;
}

21
src/PyTrigger.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
#include "Common.h"
#include "Python.h"
// Module-level Trigger enum class (created at runtime using Python's IntEnum)
// Stored as a module attribute: mcrfpy.Trigger
//
// Values represent trigger types passed to entity step() callbacks.
class PyTrigger {
public:
// Create the Trigger enum class and add to module
// Returns the enum class (new reference), or NULL on error
static PyObject* create_enum_class(PyObject* module);
// Cached reference to the Trigger enum class for fast type checking
static PyObject* trigger_enum_class;
// Number of trigger types
static const int NUM_TRIGGERS = 3;
};

View file

@ -72,6 +72,37 @@ void SpatialHash::update(std::shared_ptr<UIEntity> entity, float old_x, float ol
buckets[new_bucket].push_back(entity); buckets[new_bucket].push_back(entity);
} }
void SpatialHash::updateCell(std::shared_ptr<UIEntity> entity, int old_x, int old_y)
{
if (!entity) return;
auto old_bucket = getBucket(static_cast<float>(old_x), static_cast<float>(old_y));
auto new_bucket = getBucket(static_cast<float>(entity->cell_position.x),
static_cast<float>(entity->cell_position.y));
if (old_bucket == new_bucket) return;
// Remove from old bucket
auto it = buckets.find(old_bucket);
if (it != buckets.end()) {
auto& bucket = it->second;
bucket.erase(
std::remove_if(bucket.begin(), bucket.end(),
[&entity](const std::weak_ptr<UIEntity>& wp) {
auto sp = wp.lock();
return !sp || sp == entity;
}),
bucket.end()
);
if (bucket.empty()) {
buckets.erase(it);
}
}
// Add to new bucket
buckets[new_bucket].push_back(entity);
}
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) const std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) const
{ {
std::vector<std::shared_ptr<UIEntity>> result; std::vector<std::shared_ptr<UIEntity>> result;
@ -84,9 +115,9 @@ std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) cons
auto entity = wp.lock(); auto entity = wp.lock();
if (!entity) continue; if (!entity) continue;
// Exact integer position match // Match on cell_position (#295)
if (static_cast<int>(entity->position.x) == x && if (entity->cell_position.x == x &&
static_cast<int>(entity->position.y) == y) { entity->cell_position.y == y) {
result.push_back(entity); result.push_back(entity);
} }
} }

View file

@ -33,7 +33,11 @@ public:
// This removes from old bucket and inserts into new bucket if needed // This removes from old bucket and inserts into new bucket if needed
void update(std::shared_ptr<UIEntity> entity, float old_x, float old_y); void update(std::shared_ptr<UIEntity> entity, float old_x, float old_y);
// Query all entities at a specific cell (exact integer position match) // Update entity position using integer cell coordinates (#295)
// Removes from old bucket and inserts into new based on cell_position
void updateCell(std::shared_ptr<UIEntity> entity, int old_x, int old_y);
// Query all entities at a specific cell (uses cell_position for matching)
// O(n) where n = entities in the bucket containing this cell // O(n) where n = entities in the bucket containing this cell
std::vector<std::shared_ptr<UIEntity>> queryCell(int x, int y) const; std::vector<std::shared_ptr<UIEntity>> queryCell(int x, int y) const;

View file

@ -58,9 +58,9 @@ void UIEntity::updateVisibility()
state.visible = false; state.visible = false;
} }
// Compute FOV from entity's position using grid's FOV settings (#114) // Compute FOV from entity's cell position (#114, #295)
int x = static_cast<int>(position.x); int x = cell_position.x;
int y = static_cast<int>(position.y); int y = cell_position.y;
// Use grid's configured FOV algorithm and radius // Use grid's configured FOV algorithm and radius
grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm); grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
@ -170,19 +170,20 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
const char* name = nullptr; const char* name = nullptr;
float x = 0.0f, y = 0.0f; float x = 0.0f, y = 0.0f;
PyObject* sprite_offset_obj = nullptr; PyObject* sprite_offset_obj = nullptr;
PyObject* labels_obj = nullptr;
// Keywords list matches the new spec: positional args first, then all keyword args // Keywords list matches the new spec: positional args first, then all keyword args
static const char* kwlist[] = { static const char* kwlist[] = {
"grid_pos", "texture", "sprite_index", // Positional args (as per spec) "grid_pos", "texture", "sprite_index", // Positional args (as per spec)
// Keyword-only args // Keyword-only args
"grid", "visible", "opacity", "name", "x", "y", "sprite_offset", "grid", "visible", "opacity", "name", "x", "y", "sprite_offset", "labels",
nullptr nullptr
}; };
// Parse arguments with | for optional positional args // Parse arguments with | for optional positional args
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffO", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffOO", const_cast<char**>(kwlist),
&grid_pos_obj, &texture, &sprite_index, // Positional &grid_pos_obj, &texture, &sprite_index, // Positional
&grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj)) { &grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj, &labels_obj)) {
return -1; return -1;
} }
@ -258,6 +259,8 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
// Set position using grid coordinates // Set position using grid coordinates
self->data->position = sf::Vector2f(x, y); self->data->position = sf::Vector2f(x, y);
// #295: Initialize cell_position from grid coordinates
self->data->cell_position = sf::Vector2i(static_cast<int>(x), static_cast<int>(y));
// Handle sprite_offset argument (optional tuple, default (0,0)) // Handle sprite_offset argument (optional tuple, default (0,0))
if (sprite_offset_obj && sprite_offset_obj != Py_None) { if (sprite_offset_obj && sprite_offset_obj != Py_None) {
@ -275,6 +278,28 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
self->data->sprite.name = std::string(name); self->data->sprite.name = std::string(name);
} }
// #296 - Parse labels kwarg
if (labels_obj && labels_obj != Py_None) {
PyObject* iter = PyObject_GetIter(labels_obj);
if (!iter) {
PyErr_SetString(PyExc_TypeError, "labels must be iterable");
return -1;
}
PyObject* item;
while ((item = PyIter_Next(iter)) != NULL) {
if (!PyUnicode_Check(item)) {
Py_DECREF(item);
Py_DECREF(iter);
PyErr_SetString(PyExc_TypeError, "labels must contain only strings");
return -1;
}
self->data->labels.insert(PyUnicode_AsUTF8(item));
Py_DECREF(item);
}
Py_DECREF(iter);
if (PyErr_Occurred()) return -1;
}
// Handle grid attachment // Handle grid attachment
if (grid_obj) { if (grid_obj) {
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj; PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
@ -897,11 +922,11 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
radius = grid->fov_radius; radius = grid->fov_radius;
} }
// Get current position // Get current cell position (#295)
int x = static_cast<int>(self->data->position.x); int x = self->data->cell_position.x;
int y = static_cast<int>(self->data->position.y); int y = self->data->cell_position.y;
// Compute FOV from this entity's position // Compute FOV from this entity's cell position
grid->computeFOV(x, y, radius, true, algorithm); grid->computeFOV(x, y, radius, true, algorithm);
// Create result list // Create result list
@ -919,9 +944,9 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
continue; continue;
} }
// Check if entity is in FOV // Check if entity is in FOV (#295: use cell_position)
int ex = static_cast<int>(entity->position.x); int ex = entity->cell_position.x;
int ey = static_cast<int>(entity->position.y); int ey = entity->cell_position.y;
if (grid->isInFOV(ex, ey)) { if (grid->isInFOV(ex, ey)) {
// Create Python Entity object for this entity // Create Python Entity object for this entity
@ -1002,6 +1027,162 @@ typedef PyUIEntityObject PyObjectType;
// Combine base methods with entity-specific methods // Combine base methods with entity-specific methods
// Note: Use UIDRAWABLE_METHODS_BASE (not UIDRAWABLE_METHODS) because UIEntity is NOT a UIDrawable // Note: Use UIDRAWABLE_METHODS_BASE (not UIDRAWABLE_METHODS) because UIEntity is NOT a UIDrawable
// and the template-based animate helper won't work. Entity has its own animate() method. // and the template-based animate helper won't work. Entity has its own animate() method.
// #296 - Label system implementations
PyObject* UIEntity::get_labels(PyUIEntityObject* self, void* closure) {
PyObject* frozen = PyFrozenSet_New(NULL);
if (!frozen) return NULL;
for (const auto& label : self->data->labels) {
PyObject* str = PyUnicode_FromString(label.c_str());
if (!str) { Py_DECREF(frozen); return NULL; }
if (PySet_Add(frozen, str) < 0) {
Py_DECREF(str); Py_DECREF(frozen); return NULL;
}
Py_DECREF(str);
}
return frozen;
}
int UIEntity::set_labels(PyUIEntityObject* self, PyObject* value, void* closure) {
PyObject* iter = PyObject_GetIter(value);
if (!iter) {
PyErr_SetString(PyExc_TypeError, "labels must be iterable");
return -1;
}
std::unordered_set<std::string> new_labels;
PyObject* item;
while ((item = PyIter_Next(iter)) != NULL) {
if (!PyUnicode_Check(item)) {
Py_DECREF(item);
Py_DECREF(iter);
PyErr_SetString(PyExc_TypeError, "labels must contain only strings");
return -1;
}
new_labels.insert(PyUnicode_AsUTF8(item));
Py_DECREF(item);
}
Py_DECREF(iter);
if (PyErr_Occurred()) return -1;
self->data->labels = std::move(new_labels);
return 0;
}
PyObject* UIEntity::py_add_label(PyUIEntityObject* self, PyObject* arg) {
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "label must be a string");
return NULL;
}
self->data->labels.insert(PyUnicode_AsUTF8(arg));
Py_RETURN_NONE;
}
PyObject* UIEntity::py_remove_label(PyUIEntityObject* self, PyObject* arg) {
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "label must be a string");
return NULL;
}
self->data->labels.erase(PyUnicode_AsUTF8(arg));
Py_RETURN_NONE;
}
PyObject* UIEntity::py_has_label(PyUIEntityObject* self, PyObject* arg) {
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "label must be a string");
return NULL;
}
if (self->data->labels.count(PyUnicode_AsUTF8(arg))) {
Py_RETURN_TRUE;
}
Py_RETURN_FALSE;
}
// #299 - Step callback and default_behavior implementations
PyObject* UIEntity::get_step(PyUIEntityObject* self, void* closure) {
if (self->data->step_callback) {
Py_INCREF(self->data->step_callback);
return self->data->step_callback;
}
Py_RETURN_NONE;
}
int UIEntity::set_step(PyUIEntityObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
Py_XDECREF(self->data->step_callback);
self->data->step_callback = nullptr;
return 0;
}
if (!PyCallable_Check(value)) {
PyErr_SetString(PyExc_TypeError, "step must be callable or None");
return -1;
}
Py_XDECREF(self->data->step_callback);
Py_INCREF(value);
self->data->step_callback = value;
return 0;
}
PyObject* UIEntity::get_default_behavior(PyUIEntityObject* self, void* closure) {
return PyLong_FromLong(self->data->default_behavior);
}
int UIEntity::set_default_behavior(PyUIEntityObject* self, PyObject* value, void* closure) {
long val = PyLong_AsLong(value);
if (val == -1 && PyErr_Occurred()) return -1;
self->data->default_behavior = static_cast<int>(val);
return 0;
}
// #295 - cell_pos property implementations
PyObject* UIEntity::get_cell_pos(PyUIEntityObject* self, void* closure) {
return sfVector2i_to_PyObject(self->data->cell_position);
}
int UIEntity::set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure) {
int old_x = self->data->cell_position.x;
int old_y = self->data->cell_position.y;
sf::Vector2f vec = PyObject_to_sfVector2f(value);
if (PyErr_Occurred()) return -1;
self->data->cell_position.x = static_cast<int>(vec.x);
self->data->cell_position.y = static_cast<int>(vec.y);
// Update spatial hash
if (self->data->grid) {
self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y);
}
return 0;
}
PyObject* UIEntity::get_cell_member(PyUIEntityObject* self, void* closure) {
if (reinterpret_cast<intptr_t>(closure) == 0) {
return PyLong_FromLong(self->data->cell_position.x);
} else {
return PyLong_FromLong(self->data->cell_position.y);
}
}
int UIEntity::set_cell_member(PyUIEntityObject* self, PyObject* value, void* closure) {
long val = PyLong_AsLong(value);
if (val == -1 && PyErr_Occurred()) return -1;
int old_x = self->data->cell_position.x;
int old_y = self->data->cell_position.y;
if (reinterpret_cast<intptr_t>(closure) == 0) {
self->data->cell_position.x = static_cast<int>(val);
} else {
self->data->cell_position.y = static_cast<int>(val);
}
if (self->data->grid) {
self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y);
}
return 0;
}
PyMethodDef UIEntity_all_methods[] = { PyMethodDef UIEntity_all_methods[] = {
UIDRAWABLE_METHODS_BASE, UIDRAWABLE_METHODS_BASE,
{"animate", (PyCFunction)UIEntity::animate, METH_VARARGS | METH_KEYWORDS, {"animate", (PyCFunction)UIEntity::animate, METH_VARARGS | METH_KEYWORDS,
@ -1067,6 +1248,16 @@ PyMethodDef UIEntity_all_methods[] = {
" List of Entity objects that are within field of view.\n\n" " List of Entity objects that are within field of view.\n\n"
"Computes FOV from this entity's position and returns all other entities\n" "Computes FOV from this entity's position and returns all other entities\n"
"whose positions fall within the visible area."}, "whose positions fall within the visible area."},
// #296 - Label methods
{"add_label", (PyCFunction)UIEntity::py_add_label, METH_O,
"add_label(label: str) -> None\n\n"
"Add a label to this entity. Idempotent (adding same label twice is safe)."},
{"remove_label", (PyCFunction)UIEntity::py_remove_label, METH_O,
"remove_label(label: str) -> None\n\n"
"Remove a label from this entity. No-op if label not present."},
{"has_label", (PyCFunction)UIEntity::py_has_label, METH_O,
"has_label(label: str) -> bool\n\n"
"Check if this entity has the given label."},
{NULL} // Sentinel {NULL} // Sentinel
}; };
@ -1080,13 +1271,21 @@ PyGetSetDef UIEntity::getsetters[] = {
{"y", (getter)UIEntity::get_pixel_member, (setter)UIEntity::set_pixel_member, {"y", (getter)UIEntity::get_pixel_member, (setter)UIEntity::set_pixel_member,
"Pixel Y position relative to grid. Requires entity to be attached to a grid.", (void*)1}, "Pixel Y position relative to grid. Requires entity to be attached to a grid.", (void*)1},
// #176 - Integer tile coordinates (logical game position) // #295 - Integer cell position (decoupled from float draw_pos)
{"grid_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position, {"cell_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos,
"Grid position as integer tile coordinates (Vector). The logical cell this entity occupies.", (void*)1}, "Integer logical cell position (Vector). Decoupled from draw_pos. "
{"grid_x", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member, "Determines which cell this entity logically occupies for collision, pathfinding, etc.", NULL},
"Grid X position as integer tile coordinate.", (void*)0}, {"cell_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
{"grid_y", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member, "Integer X cell coordinate.", (void*)0},
"Grid Y position as integer tile coordinate.", (void*)1}, {"cell_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
"Integer Y cell coordinate.", (void*)1},
// grid_pos now aliases cell_pos (#295 BREAKING: no longer derives from float position)
{"grid_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos,
"Grid position as integer cell coordinates (Vector). Alias for cell_pos.", NULL},
{"grid_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
"Grid X position as integer cell coordinate. Alias for cell_x.", (void*)0},
{"grid_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
"Grid Y position as integer cell coordinate. Alias for cell_y.", (void*)1},
// Float tile coordinates (for smooth animation between tiles) // Float tile coordinates (for smooth animation between tiles)
{"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position, {"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position,
@ -1116,6 +1315,18 @@ PyGetSetDef UIEntity::getsetters[] = {
"X component of sprite pixel offset.", (void*)0}, "X component of sprite pixel offset.", (void*)0},
{"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, {"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member,
"Y component of sprite pixel offset.", (void*)1}, "Y component of sprite pixel offset.", (void*)1},
// #296 - Label system
{"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels,
"Set of string labels for collision/targeting (frozenset). "
"Assign any iterable of strings to replace all labels.", NULL},
// #299 - Step callback and default behavior
{"step", (getter)UIEntity::get_step, (setter)UIEntity::set_step,
"Step callback for grid.step() turn management. "
"Called with (trigger, data) when behavior triggers fire. "
"Set to None to clear.", NULL},
{"default_behavior", (getter)UIEntity::get_default_behavior, (setter)UIEntity::set_default_behavior,
"Default behavior type (int, maps to Behavior enum). "
"Entity reverts to this after DONE trigger. Default: 0 (IDLE).", NULL},
{NULL} /* Sentinel */ {NULL} /* Sentinel */
}; };

View file

@ -5,6 +5,8 @@
#include "IndexTexture.h" #include "IndexTexture.h"
#include "Resources.h" #include "Resources.h"
#include <list> #include <list>
#include <unordered_set>
#include <string>
#include "PyCallable.h" #include "PyCallable.h"
#include "PyTexture.h" #include "PyTexture.h"
@ -66,7 +68,11 @@ public:
std::vector<UIGridPointState> gridstate; std::vector<UIGridPointState> gridstate;
UISprite sprite; UISprite sprite;
sf::Vector2f position; //(x,y) in grid coordinates; float for animation 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)
sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom) sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom)
std::unordered_set<std::string> labels; // #296: entity label system for collision/targeting
PyObject* step_callback = nullptr; // #299: callback for grid.step() turn management
int default_behavior = 0; // #299: BehaviorType::IDLE - behavior to revert to after DONE
//void render(sf::Vector2f); //override final; //void render(sf::Vector2f); //override final;
UIEntity(); UIEntity();
@ -80,6 +86,9 @@ public:
pyobject = nullptr; pyobject = nullptr;
Py_DECREF(tmp); Py_DECREF(tmp);
} }
// #299: Clean up step callback
Py_XDECREF(step_callback);
step_callback = nullptr;
} }
// Visibility methods // Visibility methods
@ -131,6 +140,26 @@ public:
static int set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure); static int set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_sprite_offset_member(PyUIEntityObject* self, void* closure); static PyObject* get_sprite_offset_member(PyUIEntityObject* self, void* closure);
static int set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure); static int set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure);
// #295 - cell_pos (integer logical position)
static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure);
static int set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_cell_member(PyUIEntityObject* self, void* closure);
static int set_cell_member(PyUIEntityObject* self, PyObject* value, void* closure);
// #296 - Label system
static PyObject* get_labels(PyUIEntityObject* self, void* closure);
static int set_labels(PyUIEntityObject* self, PyObject* value, void* closure);
// #299 - Step callback and default behavior
static PyObject* get_step(PyUIEntityObject* self, void* closure);
static int set_step(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_default_behavior(PyUIEntityObject* self, void* closure);
static int set_default_behavior(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* py_add_label(PyUIEntityObject* self, PyObject* arg);
static PyObject* py_remove_label(PyUIEntityObject* self, PyObject* arg);
static PyObject* py_has_label(PyUIEntityObject* self, PyObject* arg);
static PyMethodDef methods[]; static PyMethodDef methods[];
static PyGetSetDef getsetters[]; static PyGetSetDef getsetters[];
static PyObject* repr(PyUIEntityObject* self); static PyObject* repr(PyUIEntityObject* self);

View file

@ -50,21 +50,23 @@ def test_entity_positions():
if abs(entity.y - 80.0) > 0.001: if abs(entity.y - 80.0) > 0.001:
errors.append(f"y: expected 80.0, got {entity.y}") errors.append(f"y: expected 80.0, got {entity.y}")
# Test 6: Setting grid_x/grid_y should update position # Test 6: Setting grid_x/grid_y should update cell position (#295: decoupled from pixel pos)
entity.grid_x = 7 entity.grid_x = 7
entity.grid_y = 2 entity.grid_y = 2
if entity.grid_x != 7 or entity.grid_y != 2: if entity.grid_x != 7 or entity.grid_y != 2:
errors.append(f"After setting grid_x/y: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})") errors.append(f"After setting grid_x/y: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
# Pixel should update too: (7, 2) * 16 = (112, 32) # #295: cell_pos (grid_x/y) is decoupled from pixel pos - pixel pos NOT updated
if abs(entity.x - 112.0) > 0.001 or abs(entity.y - 32.0) > 0.001: # Pixel pos should remain at the draw_pos * tile_size (3*16=48, 5*16=80 from earlier)
errors.append(f"After grid_x/y set, pixel pos: expected (112, 32), got ({entity.x}, {entity.y})") if abs(entity.x - 48.0) > 0.001 or abs(entity.y - 80.0) > 0.001:
errors.append(f"After grid_x/y set, pixel pos should be unchanged: expected (48, 80), got ({entity.x}, {entity.y})")
# Test 7: Setting pos (pixels) should update grid position # Test 7: Setting pos (pixels) should update draw_pos but NOT grid_pos (#295)
entity.pos = mcrfpy.Vector(64, 96) # (64, 96) / 16 = (4, 6) tiles entity.pos = mcrfpy.Vector(64, 96) # (64, 96) / 16 = (4, 6) tiles
if abs(entity.draw_pos.x - 4.0) > 0.001 or abs(entity.draw_pos.y - 6.0) > 0.001: if abs(entity.draw_pos.x - 4.0) > 0.001 or abs(entity.draw_pos.y - 6.0) > 0.001:
errors.append(f"After setting pos, draw_pos: expected (4, 6), got ({entity.draw_pos.x}, {entity.draw_pos.y})") errors.append(f"After setting pos, draw_pos: expected (4, 6), got ({entity.draw_pos.x}, {entity.draw_pos.y})")
if entity.grid_x != 4 or entity.grid_y != 6: # #295: grid_pos is cell_pos, not derived from float position - should be (7, 2) from above
errors.append(f"After setting pos, grid_x/y: expected (4, 6), got ({entity.grid_x}, {entity.grid_y})") if entity.grid_x != 7 or entity.grid_y != 2:
errors.append(f"After setting pos, grid_x/y should be unchanged: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
# Test 8: repr should show position info # Test 8: repr should show position info
repr_str = repr(entity) repr_str = repr(entity)

View file

@ -0,0 +1,78 @@
"""Regression test for #295: Entity cell_pos integer logical position."""
import mcrfpy
import sys
def test_cell_pos_init():
"""cell_pos initialized from grid_pos on construction."""
e = mcrfpy.Entity((5, 7))
assert e.cell_x == 5, f"cell_x should be 5, got {e.cell_x}"
assert e.cell_y == 7, f"cell_y should be 7, got {e.cell_y}"
print("PASS: cell_pos initialized from constructor")
def test_cell_pos_grid_pos_alias():
"""grid_pos is an alias for cell_pos."""
e = mcrfpy.Entity((3, 4))
# grid_pos should match cell_pos
assert e.grid_pos.x == e.cell_pos.x
assert e.grid_pos.y == e.cell_pos.y
# Setting grid_pos should update cell_pos
e.grid_pos = (10, 20)
assert e.cell_x == 10
assert e.cell_y == 20
print("PASS: grid_pos aliases cell_pos")
def test_cell_pos_independent_from_draw_pos():
"""cell_pos does not change when draw_pos (float position) changes."""
e = mcrfpy.Entity((5, 5))
# Change draw_pos (float position for rendering)
e.draw_pos = (5.5, 5.5)
# cell_pos should be unchanged
assert e.cell_x == 5, f"cell_x should still be 5 after draw_pos change, got {e.cell_x}"
assert e.cell_y == 5, f"cell_y should still be 5 after draw_pos change, got {e.cell_y}"
print("PASS: cell_pos independent from draw_pos")
def test_cell_pos_spatial_hash():
"""GridPoint.entities uses cell_pos for matching."""
scene = mcrfpy.Scene("test295")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
e = mcrfpy.Entity((5, 5), grid=grid)
# Entity should appear at cell (5, 5)
assert len(grid.at(5, 5).entities) == 1
# Move cell_pos to (10, 10)
e.cell_pos = (10, 10)
assert len(grid.at(5, 5).entities) == 0, "Old cell should be empty"
assert len(grid.at(10, 10).entities) == 1, "New cell should have entity"
print("PASS: spatial hash uses cell_pos")
def test_cell_pos_member_access():
"""cell_x and cell_y read/write correctly."""
e = mcrfpy.Entity((3, 7))
assert e.cell_x == 3
assert e.cell_y == 7
e.cell_x = 15
assert e.cell_x == 15
assert e.cell_y == 7 # y unchanged
e.cell_y = 20
assert e.cell_x == 15 # x unchanged
assert e.cell_y == 20
print("PASS: cell_x/cell_y member access")
if __name__ == "__main__":
test_cell_pos_init()
test_cell_pos_grid_pos_alias()
test_cell_pos_independent_from_draw_pos()
test_cell_pos_spatial_hash()
test_cell_pos_member_access()
print("All #295 tests passed")
sys.exit(0)

View file

@ -0,0 +1,70 @@
"""Unit test for #297/#298: Behavior and Trigger enums."""
import mcrfpy
import sys
def test_behavior_values():
"""All Behavior enum values are accessible and have correct int values."""
expected = {
"IDLE": 0, "CUSTOM": 1, "NOISE4": 2, "NOISE8": 3,
"PATH": 4, "WAYPOINT": 5, "PATROL": 6, "LOOP": 7,
"SLEEP": 8, "SEEK": 9, "FLEE": 10,
}
for name, value in expected.items():
b = getattr(mcrfpy.Behavior, name)
assert int(b) == value, f"Behavior.{name} should be {value}, got {int(b)}"
print("PASS: All Behavior values correct")
def test_trigger_values():
"""All Trigger enum values are accessible and have correct int values."""
expected = {"DONE": 0, "BLOCKED": 1, "TARGET": 2}
for name, value in expected.items():
t = getattr(mcrfpy.Trigger, name)
assert int(t) == value, f"Trigger.{name} should be {value}, got {int(t)}"
print("PASS: All Trigger values correct")
def test_string_comparison():
"""Enums compare equal to their name strings."""
assert mcrfpy.Behavior.IDLE == "IDLE"
assert mcrfpy.Behavior.SEEK == "SEEK"
assert mcrfpy.Trigger.DONE == "DONE"
assert not (mcrfpy.Behavior.IDLE == "SEEK")
print("PASS: String comparison works")
def test_inequality():
"""__ne__ works correctly for both string and int."""
assert mcrfpy.Behavior.IDLE != "SEEK"
assert not (mcrfpy.Behavior.IDLE != "IDLE")
assert mcrfpy.Trigger.DONE != 1
assert not (mcrfpy.Trigger.DONE != 0)
print("PASS: Inequality works")
def test_int_comparison():
"""Enums compare equal to their integer values."""
assert mcrfpy.Behavior.FLEE == 10
assert mcrfpy.Trigger.BLOCKED == 1
print("PASS: Int comparison works")
def test_repr():
"""Repr format is ClassName.MEMBER."""
assert repr(mcrfpy.Behavior.IDLE) == "Behavior.IDLE"
assert repr(mcrfpy.Trigger.TARGET) == "Trigger.TARGET"
print("PASS: Repr format correct")
def test_hashable():
"""Enum values are hashable (can be used in sets/dicts)."""
s = {mcrfpy.Behavior.IDLE, mcrfpy.Behavior.SEEK}
assert len(s) == 2
d = {mcrfpy.Trigger.DONE: "done", mcrfpy.Trigger.TARGET: "target"}
assert d[mcrfpy.Trigger.DONE] == "done"
print("PASS: Hashable")
if __name__ == "__main__":
test_behavior_values()
test_trigger_values()
test_string_comparison()
test_inequality()
test_int_comparison()
test_repr()
test_hashable()
print("All #297/#298 tests passed")
sys.exit(0)

View file

@ -0,0 +1,75 @@
"""Unit test for #296: Entity label system."""
import mcrfpy
import sys
def test_labels_crud():
"""Basic add/remove/has operations."""
e = mcrfpy.Entity()
assert len(e.labels) == 0, "New entity should have no labels"
e.add_label("solid")
assert e.has_label("solid"), "Should have 'solid' after add"
assert not e.has_label("npc"), "Should not have 'npc'"
e.add_label("npc")
assert len(e.labels) == 2
e.remove_label("solid")
assert not e.has_label("solid"), "Should not have 'solid' after remove"
assert e.has_label("npc"), "'npc' should remain"
print("PASS: labels CRUD")
def test_labels_frozenset():
"""Labels getter returns frozenset."""
e = mcrfpy.Entity()
e.add_label("a")
e.add_label("b")
labels = e.labels
assert isinstance(labels, frozenset), f"Expected frozenset, got {type(labels)}"
assert labels == frozenset({"a", "b"})
print("PASS: labels returns frozenset")
def test_labels_setter():
"""Labels setter accepts any iterable of strings."""
e = mcrfpy.Entity()
e.labels = ["x", "y", "z"]
assert e.labels == frozenset({"x", "y", "z"})
e.labels = {"replaced"}
assert e.labels == frozenset({"replaced"})
e.labels = frozenset()
assert len(e.labels) == 0
print("PASS: labels setter")
def test_labels_constructor():
"""Labels kwarg in constructor."""
e = mcrfpy.Entity(labels={"solid", "npc"})
assert e.has_label("solid")
assert e.has_label("npc")
assert len(e.labels) == 2
print("PASS: labels constructor kwarg")
def test_labels_duplicate_add():
"""Adding same label twice is idempotent."""
e = mcrfpy.Entity()
e.add_label("solid")
e.add_label("solid")
assert len(e.labels) == 1
print("PASS: duplicate add is idempotent")
def test_labels_remove_missing():
"""Removing non-existent label is a no-op."""
e = mcrfpy.Entity()
e.remove_label("nonexistent") # Should not raise
print("PASS: remove missing label is no-op")
if __name__ == "__main__":
test_labels_crud()
test_labels_frozenset()
test_labels_setter()
test_labels_constructor()
test_labels_duplicate_add()
test_labels_remove_missing()
print("All #296 tests passed")
sys.exit(0)

View file

@ -0,0 +1,72 @@
"""Unit test for #299: Entity step() callback and default_behavior."""
import mcrfpy
import sys
def test_step_callback_assignment():
"""step callback can be assigned and retrieved."""
e = mcrfpy.Entity()
assert e.step is None, "Initial step should be None"
def my_step(trigger, data):
pass
e.step = my_step
assert e.step is my_step, "step should be the assigned callable"
print("PASS: step callback assignment")
def test_step_callback_clear():
"""Setting step to None clears the callback."""
e = mcrfpy.Entity()
e.step = lambda t, d: None
assert e.step is not None
e.step = None
assert e.step is None, "step should be None after clearing"
print("PASS: step callback clear")
def test_step_callback_type_check():
"""step rejects non-callable values."""
e = mcrfpy.Entity()
try:
e.step = 42
assert False, "Should have raised TypeError"
except TypeError:
pass
print("PASS: step type check")
def test_default_behavior_roundtrip():
"""default_behavior property round-trips correctly."""
e = mcrfpy.Entity()
assert e.default_behavior == 0, "Initial default_behavior should be 0 (IDLE)"
e.default_behavior = int(mcrfpy.Behavior.SEEK)
assert e.default_behavior == 9
e.default_behavior = int(mcrfpy.Behavior.IDLE)
assert e.default_behavior == 0
print("PASS: default_behavior round-trip")
def test_step_callback_subclass():
"""Subclass def step() overrides the C getter - this is expected behavior.
Phase 3 grid.step() will check for subclass method override via PyObject_GetAttrString."""
class Guard(mcrfpy.Entity):
def on_step(self, trigger, data):
self.stepped = True
g = Guard()
# The step property should be None (no callback assigned)
assert g.step is None, "step callback should be None initially"
# Subclass methods with different names are accessible
assert hasattr(g, 'on_step'), "subclass should have on_step method"
assert callable(g.on_step)
print("PASS: subclass step method coexistence")
if __name__ == "__main__":
test_step_callback_assignment()
test_step_callback_clear()
test_step_callback_type_check()
test_default_behavior_roundtrip()
test_step_callback_subclass()
print("All #299 tests passed")
sys.exit(0)