diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index 1ef885a..cdc069c 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -361,9 +361,9 @@ void ColorLayer::updatePerspective() { if (!parent_grid) return; - // Get entity position and grid's FOV settings - int source_x = static_cast(entity->position.x); - int source_y = static_cast(entity->position.y); + // Get entity cell position and grid's FOV settings (#295) + int source_x = entity->cell_position.x; + int source_y = entity->cell_position.y; int radius = parent_grid->fov_radius; TCOD_fov_algorithm_t algorithm = parent_grid->fov_algorithm; diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 328be76..f31b62e 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -16,6 +16,8 @@ #include "PyKey.h" #include "PyMouseButton.h" #include "PyInputState.h" +#include "PyBehavior.h" +#include "PyTrigger.h" #include "PySound.h" #include "PySoundBuffer.h" #include "PyMusic.h" @@ -779,6 +781,18 @@ PyObject* PyInit_mcrfpy() 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 PyObject* automation_module = McRFPy_Automation::init_automation_module(); if (automation_module != NULL) { diff --git a/src/PyBehavior.cpp b/src/PyBehavior.cpp new file mode 100644 index 0000000..bf69994 --- /dev/null +++ b/src/PyBehavior.cpp @@ -0,0 +1,116 @@ +#include "PyBehavior.h" +#include + +// 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; +} diff --git a/src/PyBehavior.h b/src/PyBehavior.h new file mode 100644 index 0000000..5772500 --- /dev/null +++ b/src/PyBehavior.h @@ -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; +}; diff --git a/src/PyTrigger.cpp b/src/PyTrigger.cpp new file mode 100644 index 0000000..cc764ce --- /dev/null +++ b/src/PyTrigger.cpp @@ -0,0 +1,108 @@ +#include "PyTrigger.h" +#include + +// 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; +} diff --git a/src/PyTrigger.h b/src/PyTrigger.h new file mode 100644 index 0000000..3dd5dde --- /dev/null +++ b/src/PyTrigger.h @@ -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; +}; diff --git a/src/SpatialHash.cpp b/src/SpatialHash.cpp index ffd1b94..7cc1881 100644 --- a/src/SpatialHash.cpp +++ b/src/SpatialHash.cpp @@ -72,6 +72,37 @@ void SpatialHash::update(std::shared_ptr entity, float old_x, float ol buckets[new_bucket].push_back(entity); } +void SpatialHash::updateCell(std::shared_ptr entity, int old_x, int old_y) +{ + if (!entity) return; + + auto old_bucket = getBucket(static_cast(old_x), static_cast(old_y)); + auto new_bucket = getBucket(static_cast(entity->cell_position.x), + static_cast(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& 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> SpatialHash::queryCell(int x, int y) const { std::vector> result; @@ -84,9 +115,9 @@ std::vector> SpatialHash::queryCell(int x, int y) cons auto entity = wp.lock(); if (!entity) continue; - // Exact integer position match - if (static_cast(entity->position.x) == x && - static_cast(entity->position.y) == y) { + // Match on cell_position (#295) + if (entity->cell_position.x == x && + entity->cell_position.y == y) { result.push_back(entity); } } diff --git a/src/SpatialHash.h b/src/SpatialHash.h index afc64df..bb40cf3 100644 --- a/src/SpatialHash.h +++ b/src/SpatialHash.h @@ -33,7 +33,11 @@ public: // This removes from old bucket and inserts into new bucket if needed void update(std::shared_ptr 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 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 std::vector> queryCell(int x, int y) const; diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 81e5e39..e4ee8fa 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -58,9 +58,9 @@ void UIEntity::updateVisibility() state.visible = false; } - // Compute FOV from entity's position using grid's FOV settings (#114) - int x = static_cast(position.x); - int y = static_cast(position.y); + // Compute FOV from entity's cell position (#114, #295) + int x = cell_position.x; + int y = cell_position.y; // Use grid's configured FOV algorithm and radius 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; float x = 0.0f, y = 0.0f; PyObject* sprite_offset_obj = nullptr; + PyObject* labels_obj = nullptr; // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { "grid_pos", "texture", "sprite_index", // Positional args (as per spec) // Keyword-only args - "grid", "visible", "opacity", "name", "x", "y", "sprite_offset", + "grid", "visible", "opacity", "name", "x", "y", "sprite_offset", "labels", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffO", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffOO", const_cast(kwlist), &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; } @@ -258,6 +259,8 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { // Set position using grid coordinates self->data->position = sf::Vector2f(x, y); + // #295: Initialize cell_position from grid coordinates + self->data->cell_position = sf::Vector2i(static_cast(x), static_cast(y)); // Handle sprite_offset argument (optional tuple, default (0,0)) 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); } + // #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 if (grid_obj) { PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj; @@ -897,11 +922,11 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO radius = grid->fov_radius; } - // Get current position - int x = static_cast(self->data->position.x); - int y = static_cast(self->data->position.y); + // Get current cell position (#295) + int x = self->data->cell_position.x; + 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); // Create result list @@ -919,9 +944,9 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO continue; } - // Check if entity is in FOV - int ex = static_cast(entity->position.x); - int ey = static_cast(entity->position.y); + // Check if entity is in FOV (#295: use cell_position) + int ex = entity->cell_position.x; + int ey = entity->cell_position.y; if (grid->isInFOV(ex, ey)) { // Create Python Entity object for this entity @@ -1002,6 +1027,162 @@ typedef PyUIEntityObject PyObjectType; // Combine base methods with entity-specific methods // 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. +// #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 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(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(vec.x); + self->data->cell_position.y = static_cast(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(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(closure) == 0) { + self->data->cell_position.x = static_cast(val); + } else { + self->data->cell_position.y = static_cast(val); + } + + if (self->data->grid) { + self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y); + } + return 0; +} + PyMethodDef UIEntity_all_methods[] = { UIDRAWABLE_METHODS_BASE, {"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" "Computes FOV from this entity's position and returns all other entities\n" "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 }; @@ -1080,13 +1271,21 @@ PyGetSetDef UIEntity::getsetters[] = { {"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}, - // #176 - Integer tile coordinates (logical game position) - {"grid_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position, - "Grid position as integer tile coordinates (Vector). The logical cell this entity occupies.", (void*)1}, - {"grid_x", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member, - "Grid X position as integer tile coordinate.", (void*)0}, - {"grid_y", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member, - "Grid Y position as integer tile coordinate.", (void*)1}, + // #295 - Integer cell position (decoupled from float draw_pos) + {"cell_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos, + "Integer logical cell position (Vector). Decoupled from draw_pos. " + "Determines which cell this entity logically occupies for collision, pathfinding, etc.", NULL}, + {"cell_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member, + "Integer X cell coordinate.", (void*)0}, + {"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) {"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}, {"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, "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 */ }; diff --git a/src/UIEntity.h b/src/UIEntity.h index ca2ac31..7b905df 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -5,6 +5,8 @@ #include "IndexTexture.h" #include "Resources.h" #include +#include +#include #include "PyCallable.h" #include "PyTexture.h" @@ -66,7 +68,11 @@ public: std::vector gridstate; 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) sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom) + std::unordered_set 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; UIEntity(); @@ -80,6 +86,9 @@ public: pyobject = nullptr; Py_DECREF(tmp); } + // #299: Clean up step callback + Py_XDECREF(step_callback); + step_callback = nullptr; } // Visibility methods @@ -131,6 +140,26 @@ public: static int set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure); static PyObject* get_sprite_offset_member(PyUIEntityObject* self, 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 PyGetSetDef getsetters[]; static PyObject* repr(PyUIEntityObject* self); diff --git a/tests/regression/issue_176_entity_position_test.py b/tests/regression/issue_176_entity_position_test.py index 356cd2b..17b3db9 100644 --- a/tests/regression/issue_176_entity_position_test.py +++ b/tests/regression/issue_176_entity_position_test.py @@ -50,21 +50,23 @@ def test_entity_positions(): if abs(entity.y - 80.0) > 0.001: 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_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})") - # Pixel should update too: (7, 2) * 16 = (112, 32) - if abs(entity.x - 112.0) > 0.001 or abs(entity.y - 32.0) > 0.001: - errors.append(f"After grid_x/y set, pixel pos: expected (112, 32), got ({entity.x}, {entity.y})") + # #295: cell_pos (grid_x/y) is decoupled from pixel pos - pixel pos NOT updated + # Pixel pos should remain at the draw_pos * tile_size (3*16=48, 5*16=80 from earlier) + 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 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})") - if entity.grid_x != 4 or entity.grid_y != 6: - errors.append(f"After setting pos, grid_x/y: expected (4, 6), got ({entity.grid_x}, {entity.grid_y})") + # #295: grid_pos is cell_pos, not derived from float position - should be (7, 2) from above + 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 repr_str = repr(entity) diff --git a/tests/regression/issue_295_cell_pos_test.py b/tests/regression/issue_295_cell_pos_test.py new file mode 100644 index 0000000..b7057e2 --- /dev/null +++ b/tests/regression/issue_295_cell_pos_test.py @@ -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) diff --git a/tests/unit/behavior_trigger_enum_test.py b/tests/unit/behavior_trigger_enum_test.py new file mode 100644 index 0000000..86b3372 --- /dev/null +++ b/tests/unit/behavior_trigger_enum_test.py @@ -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) diff --git a/tests/unit/entity_labels_test.py b/tests/unit/entity_labels_test.py new file mode 100644 index 0000000..4aefc49 --- /dev/null +++ b/tests/unit/entity_labels_test.py @@ -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) diff --git a/tests/unit/entity_step_callback_test.py b/tests/unit/entity_step_callback_test.py new file mode 100644 index 0000000..1447bce --- /dev/null +++ b/tests/unit/entity_step_callback_test.py @@ -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)