From d6ef29f3cd6aa8b245026689a46cefac638a49e3 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 10 Jan 2026 08:37:31 -0500 Subject: [PATCH 1/3] Grid code quality improvements * Grid [x, y] subscript - convenience for `.at()` * Extract UIEntityCollection - cleanup of UIGrid.cpp * Thread-safe type cache - PyTypeCache * Exception-safe extend() - validate before modify --- src/McRFPy_API.cpp | 13 +- src/PyTypeCache.cpp | 135 +++++ src/PyTypeCache.h | 64 +++ src/UIEntityCollection.cpp | 1078 ++++++++++++++++++++++++++++++++++++ src/UIEntityCollection.h | 145 +++++ src/UIGrid.cpp | 1040 ++-------------------------------- src/UIGrid.h | 114 +--- 7 files changed, 1492 insertions(+), 1097 deletions(-) create mode 100644 src/PyTypeCache.cpp create mode 100644 src/PyTypeCache.h create mode 100644 src/UIEntityCollection.cpp create mode 100644 src/UIEntityCollection.h diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index d9ea65f..5bf0400 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -3,6 +3,7 @@ #include "McRFPy_Automation.h" #include "McRFPy_Libtcod.h" #include "McRFPy_Doc.h" +#include "PyTypeCache.h" // Thread-safe cached Python types #include "platform.h" #include "PyAnimation.h" #include "PyDrawable.h" @@ -549,12 +550,20 @@ PyObject* PyInit_mcrfpy() PyObject* libtcod_module = McRFPy_Libtcod::init_libtcod_module(); if (libtcod_module != NULL) { PyModule_AddObject(m, "libtcod", libtcod_module); - + // Also add to sys.modules for proper import behavior PyObject* sys_modules = PyImport_GetModuleDict(); PyDict_SetItemString(sys_modules, "mcrfpy.libtcod", libtcod_module); } - + + // Initialize PyTypeCache for thread-safe type lookups + // This must be done after all types are added to the module + if (!PyTypeCache::initialize(m)) { + // Failed to initialize type cache - this is a critical error + // Error message already set by PyTypeCache::initialize + return NULL; + } + //McRFPy_API::mcrf_module = m; return m; } diff --git a/src/PyTypeCache.cpp b/src/PyTypeCache.cpp new file mode 100644 index 0000000..22d18a9 --- /dev/null +++ b/src/PyTypeCache.cpp @@ -0,0 +1,135 @@ +// PyTypeCache.cpp - Thread-safe Python type caching implementation +#include "PyTypeCache.h" + +// Static member definitions +std::atomic PyTypeCache::entity_type{nullptr}; +std::atomic PyTypeCache::grid_type{nullptr}; +std::atomic PyTypeCache::frame_type{nullptr}; +std::atomic PyTypeCache::caption_type{nullptr}; +std::atomic PyTypeCache::sprite_type{nullptr}; +std::atomic PyTypeCache::texture_type{nullptr}; +std::atomic PyTypeCache::color_type{nullptr}; +std::atomic PyTypeCache::vector_type{nullptr}; +std::atomic PyTypeCache::font_type{nullptr}; +std::atomic PyTypeCache::initialized{false}; +std::mutex PyTypeCache::init_mutex; + +PyTypeObject* PyTypeCache::cacheType(PyObject* module, const char* name, + std::atomic& cache) { + PyObject* type_obj = PyObject_GetAttrString(module, name); + if (!type_obj) { + PyErr_Format(PyExc_RuntimeError, + "PyTypeCache: Failed to get type '%s' from module", name); + return nullptr; + } + + if (!PyType_Check(type_obj)) { + Py_DECREF(type_obj); + PyErr_Format(PyExc_TypeError, + "PyTypeCache: '%s' is not a type object", name); + return nullptr; + } + + // Store in cache - we keep the reference permanently + // Using memory_order_release ensures the pointer is visible to other threads + // after they see initialized=true + cache.store((PyTypeObject*)type_obj, std::memory_order_release); + + return (PyTypeObject*)type_obj; +} + +bool PyTypeCache::initialize(PyObject* module) { + std::lock_guard lock(init_mutex); + + // Double-check pattern - might have been initialized while waiting for lock + if (initialized.load(std::memory_order_acquire)) { + return true; + } + + // Cache all types + if (!cacheType(module, "Entity", entity_type)) return false; + if (!cacheType(module, "Grid", grid_type)) return false; + if (!cacheType(module, "Frame", frame_type)) return false; + if (!cacheType(module, "Caption", caption_type)) return false; + if (!cacheType(module, "Sprite", sprite_type)) return false; + if (!cacheType(module, "Texture", texture_type)) return false; + if (!cacheType(module, "Color", color_type)) return false; + if (!cacheType(module, "Vector", vector_type)) return false; + if (!cacheType(module, "Font", font_type)) return false; + + // Mark as initialized - release ensures all stores above are visible + initialized.store(true, std::memory_order_release); + + return true; +} + +void PyTypeCache::finalize() { + std::lock_guard lock(init_mutex); + + if (!initialized.load(std::memory_order_acquire)) { + return; + } + + // Release all cached references + auto release = [](std::atomic& cache) { + PyTypeObject* type = cache.exchange(nullptr, std::memory_order_acq_rel); + if (type) { + Py_DECREF(type); + } + }; + + release(entity_type); + release(grid_type); + release(frame_type); + release(caption_type); + release(sprite_type); + release(texture_type); + release(color_type); + release(vector_type); + release(font_type); + + initialized.store(false, std::memory_order_release); +} + +bool PyTypeCache::isInitialized() { + return initialized.load(std::memory_order_acquire); +} + +// Type accessors - lock-free reads after initialization +// Using memory_order_acquire ensures we see the pointer stored during init + +PyTypeObject* PyTypeCache::Entity() { + return entity_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Grid() { + return grid_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Frame() { + return frame_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Caption() { + return caption_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Sprite() { + return sprite_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Texture() { + return texture_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Color() { + return color_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Vector() { + return vector_type.load(std::memory_order_acquire); +} + +PyTypeObject* PyTypeCache::Font() { + return font_type.load(std::memory_order_acquire); +} diff --git a/src/PyTypeCache.h b/src/PyTypeCache.h new file mode 100644 index 0000000..633cb54 --- /dev/null +++ b/src/PyTypeCache.h @@ -0,0 +1,64 @@ +#pragma once +// PyTypeCache.h - Thread-safe caching of Python type objects +// +// This module provides a centralized, thread-safe way to cache Python type +// references. It eliminates the refcount leaks from repeated +// PyObject_GetAttrString calls and is compatible with free-threading (PEP 703). +// +// Usage: +// PyTypeObject* entity_type = PyTypeCache::Entity(); +// if (!entity_type) return NULL; // Error already set +// +// The cache is populated during module initialization and the types are +// held for the lifetime of the interpreter. + +#include "Python.h" +#include +#include + +class PyTypeCache { +public: + // Initialize the cache - call once during module init after types are ready + // Returns false and sets Python error on failure + static bool initialize(PyObject* module); + + // Finalize - release references (call during module cleanup if needed) + static void finalize(); + + // Type accessors - return borrowed references (no DECREF needed) + // These are thread-safe and lock-free after initialization + static PyTypeObject* Entity(); + static PyTypeObject* Grid(); + static PyTypeObject* Frame(); + static PyTypeObject* Caption(); + static PyTypeObject* Sprite(); + static PyTypeObject* Texture(); + static PyTypeObject* Color(); + static PyTypeObject* Vector(); + static PyTypeObject* Font(); + + // Check if initialized + static bool isInitialized(); + +private: + // Cached type pointers - atomic for thread-safe reads + static std::atomic entity_type; + static std::atomic grid_type; + static std::atomic frame_type; + static std::atomic caption_type; + static std::atomic sprite_type; + static std::atomic texture_type; + static std::atomic color_type; + static std::atomic vector_type; + static std::atomic font_type; + + // Initialization flag + static std::atomic initialized; + + // Mutex for initialization (only used during init, not for reads) + static std::mutex init_mutex; + + // Helper to fetch and cache a type + static PyTypeObject* cacheType(PyObject* module, const char* name, + std::atomic& cache); +}; diff --git a/src/UIEntityCollection.cpp b/src/UIEntityCollection.cpp new file mode 100644 index 0000000..cd11181 --- /dev/null +++ b/src/UIEntityCollection.cpp @@ -0,0 +1,1078 @@ +// UIEntityCollection.cpp - Implementation of EntityCollection Python type +// +// Extracted from UIGrid.cpp as part of code organization cleanup. +// This file contains all EntityCollection and UIEntityCollectionIter methods. + +#include "UIEntityCollection.h" +#include "UIEntity.h" +#include "UIGrid.h" +#include "McRFPy_API.h" +#include "PyTypeCache.h" +#include "PythonObjectCache.h" +#include +#include + +// ============================================================================ +// UIEntityCollectionIter implementation +// ============================================================================ + +int UIEntityCollectionIter::init(PyUIEntityCollectionIterObject* self, PyObject* args, PyObject* kwds) +{ + PyErr_SetString(PyExc_TypeError, "UIEntityCollectionIter cannot be instantiated directly"); + return -1; +} + +PyObject* UIEntityCollectionIter::next(PyUIEntityCollectionIterObject* self) +{ + // Check for collection modification during iteration + if (static_cast(self->data->size()) != self->start_size) + { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection changed size during iteration"); + return NULL; + } + + // Check if we've reached the end + if (self->current == self->end) + { + PyErr_SetNone(PyExc_StopIteration); + return NULL; + } + + // Get current element and advance iterator - O(1) operation + auto target = *self->current; + ++self->current; + + // Return the stored Python object if it exists (preserves derived types) + if (target->self != nullptr) { + Py_INCREF(target->self); + return target->self; + } + + // Otherwise create and return a new Python Entity object + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!o) return NULL; + + o->data = std::static_pointer_cast(target); + o->weakreflist = NULL; + return (PyObject*)o; +} + +PyObject* UIEntityCollectionIter::repr(PyUIEntityCollectionIterObject* self) +{ + std::ostringstream ss; + if (!self->data) { + ss << ""; + } else { + auto remaining = std::distance(self->current, self->end); + auto total = self->data->size(); + ss << ""; + } + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +// ============================================================================ +// UIEntityCollection - Sequence protocol +// ============================================================================ + +Py_ssize_t UIEntityCollection::len(PyUIEntityCollectionObject* self) { + return self->data ? self->data->size() : 0; +} + +PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize_t index) { + auto vec = self->data.get(); + if (!vec) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + // Handle negative indexing + Py_ssize_t size = static_cast(vec->size()); + if (index < 0) index += size; + if (index < 0 || index >= size) { + PyErr_SetString(PyExc_IndexError, "EntityCollection index out of range"); + return NULL; + } + + auto it = vec->begin(); + std::advance(it, index); + auto target = *it; + + // Check cache first to preserve derived class + if (target->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number); + if (cached) { + return cached; // Already INCREF'd by lookup + } + } + + // Legacy: If the entity has a stored Python object reference, return that + if (target->self != nullptr) { + Py_INCREF(target->self); + return target->self; + } + + // Otherwise, create a new base Entity object + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!o) return NULL; + + o->data = std::static_pointer_cast(target); + o->weakreflist = NULL; + return (PyObject*)o; +} + +int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value) { + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return -1; + } + + // Handle negative indexing + Py_ssize_t size = static_cast(list->size()); + if (index < 0) index += size; + if (index < 0 || index >= size) { + PyErr_SetString(PyExc_IndexError, "EntityCollection assignment index out of range"); + return -1; + } + + auto it = list->begin(); + std::advance(it, index); + + // Handle deletion + if (value == NULL) { + // Remove from spatial hash before removing from list + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + (*it)->grid = nullptr; + list->erase(it); + return 0; + } + + // Type checking using cached type + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return -1; + } + + if (!PyObject_IsInstance(value, (PyObject*)entity_type)) { + PyErr_SetString(PyExc_TypeError, "EntityCollection can only contain Entity objects"); + return -1; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)value; + if (!entity->data) { + PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); + return -1; + } + + // Update spatial hash + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + + // Clear grid reference from the old entity + (*it)->grid = nullptr; + + // Replace the element and set grid reference + *it = entity->data; + entity->data->grid = self->grid; + + // Add to spatial hash + if (self->grid) { + self->grid->spatial_hash.insert(entity->data); + } + + return 0; +} + +int UIEntityCollection::contains(PyUIEntityCollectionObject* self, PyObject* value) { + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return -1; + } + + // Type checking using cached type + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type || !PyObject_IsInstance(value, (PyObject*)entity_type)) { + return 0; // Not an Entity, can't be in collection + } + + PyUIEntityObject* entity = (PyUIEntityObject*)value; + if (!entity->data) { + return 0; + } + + // Search by comparing C++ pointers + for (const auto& ent : *list) { + if (ent.get() == entity->data.get()) { + return 1; + } + } + + return 0; +} + +PyObject* UIEntityCollection::concat(PyUIEntityCollectionObject* self, PyObject* other) { + if (!PySequence_Check(other)) { + PyErr_SetString(PyExc_TypeError, "can only concatenate sequence to EntityCollection"); + return NULL; + } + + Py_ssize_t self_len = self->data->size(); + Py_ssize_t other_len = PySequence_Length(other); + if (other_len == -1) { + return NULL; + } + + PyObject* result_list = PyList_New(self_len + other_len); + if (!result_list) { + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + Py_DECREF(result_list); + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + // Add all elements from self + Py_ssize_t idx = 0; + for (const auto& entity : *self->data) { + auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!obj) { + Py_DECREF(result_list); + return NULL; + } + obj->data = entity; + obj->weakreflist = NULL; + PyList_SET_ITEM(result_list, idx++, (PyObject*)obj); + } + + // Add all elements from other + for (Py_ssize_t i = 0; i < other_len; i++) { + PyObject* item = PySequence_GetItem(other, i); + if (!item) { + Py_DECREF(result_list); + return NULL; + } + PyList_SET_ITEM(result_list, self_len + i, item); + } + + return result_list; +} + +PyObject* UIEntityCollection::inplace_concat(PyUIEntityCollectionObject* self, PyObject* other) { + if (!PySequence_Check(other)) { + PyErr_SetString(PyExc_TypeError, "can only concatenate sequence to EntityCollection"); + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + // First, validate ALL items before modifying anything + Py_ssize_t other_len = PySequence_Length(other); + if (other_len == -1) { + return NULL; + } + + for (Py_ssize_t i = 0; i < other_len; i++) { + PyObject* item = PySequence_GetItem(other, i); + if (!item) { + return NULL; + } + + if (!PyObject_IsInstance(item, (PyObject*)entity_type)) { + Py_DECREF(item); + PyErr_Format(PyExc_TypeError, + "EntityCollection can only contain Entity objects; " + "got %s at index %zd", Py_TYPE(item)->tp_name, i); + return NULL; + } + Py_DECREF(item); + } + + // All items validated, now safely add them + for (Py_ssize_t i = 0; i < other_len; i++) { + PyObject* item = PySequence_GetItem(other, i); + if (!item) { + return NULL; + } + + PyObject* result = append(self, item); + Py_DECREF(item); + + if (!result) { + return NULL; + } + Py_DECREF(result); + } + + Py_INCREF(self); + return (PyObject*)self; +} + +PySequenceMethods UIEntityCollection::sqmethods = { + .sq_length = (lenfunc)UIEntityCollection::len, + .sq_concat = (binaryfunc)UIEntityCollection::concat, + .sq_repeat = NULL, + .sq_item = (ssizeargfunc)UIEntityCollection::getitem, + .was_sq_slice = NULL, + .sq_ass_item = (ssizeobjargproc)UIEntityCollection::setitem, + .was_sq_ass_slice = NULL, + .sq_contains = (objobjproc)UIEntityCollection::contains, + .sq_inplace_concat = (binaryfunc)UIEntityCollection::inplace_concat, + .sq_inplace_repeat = NULL +}; + +// ============================================================================ +// UIEntityCollection - Mapping protocol (for slice support) +// ============================================================================ + +PyObject* UIEntityCollection::subscript(PyUIEntityCollectionObject* self, PyObject* key) { + if (PyLong_Check(key)) { + Py_ssize_t index = PyLong_AsSsize_t(key); + if (index == -1 && PyErr_Occurred()) { + return NULL; + } + return getitem(self, index); + } else if (PySlice_Check(key)) { + Py_ssize_t start, stop, step, slicelength; + + if (PySlice_GetIndicesEx(key, self->data->size(), &start, &stop, &step, &slicelength) < 0) { + return NULL; + } + + PyObject* result_list = PyList_New(slicelength); + if (!result_list) { + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + Py_DECREF(result_list); + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + auto it = self->data->begin(); + for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { + auto cur_it = it; + std::advance(cur_it, cur); + + auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!obj) { + Py_DECREF(result_list); + return NULL; + } + obj->data = *cur_it; + obj->weakreflist = NULL; + PyList_SET_ITEM(result_list, i, (PyObject*)obj); + } + + return result_list; + } else { + PyErr_Format(PyExc_TypeError, + "EntityCollection indices must be integers or slices, not %.200s", + Py_TYPE(key)->tp_name); + return NULL; + } +} + +int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject* key, PyObject* value) { + if (PyLong_Check(key)) { + Py_ssize_t index = PyLong_AsSsize_t(key); + if (index == -1 && PyErr_Occurred()) { + return -1; + } + return setitem(self, index, value); + } else if (PySlice_Check(key)) { + Py_ssize_t start, stop, step, slicelength; + + if (PySlice_GetIndicesEx(key, self->data->size(), &start, &stop, &step, &slicelength) < 0) { + return -1; + } + + // Handle deletion + if (value == NULL) { + if (step == 1) { + // Contiguous slice deletion + auto start_it = self->data->begin(); + std::advance(start_it, start); + auto stop_it = self->data->begin(); + std::advance(stop_it, stop); + + // Clear grid refs and remove from spatial hash + for (auto it = start_it; it != stop_it; ++it) { + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + (*it)->grid = nullptr; + } + self->data->erase(start_it, stop_it); + } else { + // Extended slice deletion - must delete in reverse to preserve indices + std::vector indices; + for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { + indices.push_back(cur); + } + std::sort(indices.rbegin(), indices.rend()); + + for (Py_ssize_t idx : indices) { + auto it = self->data->begin(); + std::advance(it, idx); + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + (*it)->grid = nullptr; + self->data->erase(it); + } + } + return 0; + } + + // Handle assignment + if (!PySequence_Check(value)) { + PyErr_SetString(PyExc_TypeError, "can only assign sequence to slice"); + return -1; + } + + Py_ssize_t value_len = PySequence_Length(value); + if (value_len == -1) { + return -1; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return -1; + } + + // Validate all items first + std::vector> new_items; + for (Py_ssize_t i = 0; i < value_len; i++) { + PyObject* item = PySequence_GetItem(value, i); + if (!item) { + return -1; + } + + if (!PyObject_IsInstance(item, (PyObject*)entity_type)) { + Py_DECREF(item); + PyErr_Format(PyExc_TypeError, + "EntityCollection can only contain Entity objects; got %s", + Py_TYPE(item)->tp_name); + return -1; + } + + PyUIEntityObject* entity_obj = (PyUIEntityObject*)item; + new_items.push_back(entity_obj->data); + Py_DECREF(item); + } + + if (step == 1) { + // Contiguous slice - can change size + auto start_it = self->data->begin(); + std::advance(start_it, start); + auto stop_it = self->data->begin(); + std::advance(stop_it, stop); + + // Clear old grid refs and remove from spatial hash + for (auto it = start_it; it != stop_it; ++it) { + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + (*it)->grid = nullptr; + } + + // Erase old range + auto insert_pos = self->data->erase(start_it, stop_it); + + // Insert new items + for (const auto& entity : new_items) { + self->data->insert(insert_pos, entity); + entity->grid = self->grid; + if (self->grid) { + self->grid->spatial_hash.insert(entity); + } + } + } else { + // Extended slice - must match size + if (slicelength != value_len) { + PyErr_Format(PyExc_ValueError, + "attempt to assign sequence of size %zd to extended slice of size %zd", + value_len, slicelength); + return -1; + } + + auto it = self->data->begin(); + size_t new_idx = 0; + for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { + auto cur_it = it; + std::advance(cur_it, cur); + + if (self->grid) { + self->grid->spatial_hash.remove(*cur_it); + } + (*cur_it)->grid = nullptr; + + *cur_it = new_items[new_idx++]; + (*cur_it)->grid = self->grid; + + if (self->grid) { + self->grid->spatial_hash.insert(*cur_it); + } + } + } + + return 0; + } else { + PyErr_Format(PyExc_TypeError, + "EntityCollection indices must be integers or slices, not %.200s", + Py_TYPE(key)->tp_name); + return -1; + } +} + +PyMappingMethods UIEntityCollection::mpmethods = { + .mp_length = (lenfunc)UIEntityCollection::len, + .mp_subscript = (binaryfunc)UIEntityCollection::subscript, + .mp_ass_subscript = (objobjargproc)UIEntityCollection::ass_subscript +}; + +// ============================================================================ +// UIEntityCollection - Methods +// ============================================================================ + +PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject* o) +{ + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + if (!PyObject_IsInstance(o, (PyObject*)entity_type)) { + PyErr_SetString(PyExc_TypeError, "Only Entity objects can be added to EntityCollection"); + return NULL; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)o; + + // Remove from old grid first (if different from target grid) + if (entity->data->grid && entity->data->grid != self->grid) { + auto old_grid = entity->data->grid; + auto& old_entities = old_grid->entities; + auto it = std::find_if(old_entities->begin(), old_entities->end(), + [entity](const std::shared_ptr& e) { + return e.get() == entity->data.get(); + }); + if (it != old_entities->end()) { + old_entities->erase(it); + old_grid->spatial_hash.remove(entity->data); + } + } + + // Add to this grid (if not already in it) + if (entity->data->grid != self->grid) { + self->data->push_back(entity->data); + entity->data->grid = self->grid; + if (self->grid) { + self->grid->spatial_hash.insert(entity->data); + } + } + + // Initialize gridstate if not already done + if (entity->data->gridstate.size() == 0 && self->grid) { + entity->data->gridstate.resize(self->grid->grid_w * self->grid->grid_h); + for (auto& state : entity->data->gridstate) { + state.visible = false; + state.discovered = false; + } + } + + Py_RETURN_NONE; +} + +PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject* o) +{ + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + if (!PyObject_IsInstance(o, (PyObject*)entity_type)) { + PyErr_SetString(PyExc_TypeError, "EntityCollection.remove requires an Entity object"); + return NULL; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)o; + if (!entity->data) { + PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); + return NULL; + } + + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + // Search by comparing C++ pointers + for (auto it = list->begin(); it != list->end(); ++it) { + if (it->get() == entity->data.get()) { + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + (*it)->grid = nullptr; + list->erase(it); + Py_RETURN_NONE; + } + } + + PyErr_SetString(PyExc_ValueError, "Entity not in EntityCollection"); + return NULL; +} + +PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject* o) +{ + // Get iterator for the input + PyObject* iterator = PyObject_GetIter(o); + if (!iterator) { + PyErr_SetString(PyExc_TypeError, "EntityCollection.extend requires an iterable"); + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + Py_DECREF(iterator); + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + // FIXED: Validate ALL items first before modifying anything + // (Following the pattern from inplace_concat) + std::vector validated_entities; + PyObject* item; + while ((item = PyIter_Next(iterator)) != NULL) { + if (!PyObject_IsInstance(item, (PyObject*)entity_type)) { + // Cleanup on error + Py_DECREF(item); + Py_DECREF(iterator); + PyErr_SetString(PyExc_TypeError, "All items in iterable must be Entity objects"); + return NULL; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)item; + if (!entity->data) { + Py_DECREF(item); + Py_DECREF(iterator); + PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object in iterable"); + return NULL; + } + + validated_entities.push_back(entity); + // Note: We keep the reference to item until after we're done + } + + Py_DECREF(iterator); + + // Check if iteration ended due to an error + if (PyErr_Occurred()) { + // Release all held references + for (auto* ent : validated_entities) { + Py_DECREF(ent); + } + return NULL; + } + + // All items validated - now we can safely add them + for (auto* entity : validated_entities) { + self->data->push_back(entity->data); + entity->data->grid = self->grid; + + if (self->grid) { + self->grid->spatial_hash.insert(entity->data); + } + + // Initialize gridstate if needed + if (entity->data->gridstate.size() == 0 && self->grid) { + entity->data->gridstate.resize(self->grid->grid_w * self->grid->grid_h); + for (auto& state : entity->data->gridstate) { + state.visible = false; + state.discovered = false; + } + } + + Py_DECREF(entity); // Release the reference we held during validation + } + + Py_RETURN_NONE; +} + +PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* args) +{ + Py_ssize_t index = -1; + + if (!PyArg_ParseTuple(args, "|n", &index)) { + return NULL; + } + + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + if (list->empty()) { + PyErr_SetString(PyExc_IndexError, "pop from empty EntityCollection"); + return NULL; + } + + // Handle negative indexing + Py_ssize_t size = static_cast(list->size()); + if (index < 0) { + index += size; + } + + if (index < 0 || index >= size) { + PyErr_SetString(PyExc_IndexError, "pop index out of range"); + return NULL; + } + + auto it = list->begin(); + std::advance(it, index); + + std::shared_ptr entity = *it; + + // Remove from spatial hash and clear grid reference + if (self->grid) { + self->grid->spatial_hash.remove(entity); + } + entity->grid = nullptr; + list->erase(it); + + // Create Python object for the entity + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + PyUIEntityObject* py_entity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!py_entity) { + return NULL; + } + + py_entity->data = entity; + py_entity->weakreflist = NULL; + + return (PyObject*)py_entity; +} + +PyObject* UIEntityCollection::insert(PyUIEntityCollectionObject* self, PyObject* args) +{ + Py_ssize_t index; + PyObject* o; + + if (!PyArg_ParseTuple(args, "nO", &index, &o)) { + return NULL; + } + + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + if (!PyObject_IsInstance(o, (PyObject*)entity_type)) { + PyErr_SetString(PyExc_TypeError, "EntityCollection.insert requires an Entity object"); + return NULL; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)o; + if (!entity->data) { + PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); + return NULL; + } + + // Handle negative indexing and clamping (Python list.insert behavior) + Py_ssize_t size = static_cast(list->size()); + if (index < 0) { + index += size; + if (index < 0) { + index = 0; + } + } else if (index > size) { + index = size; + } + + auto it = list->begin(); + std::advance(it, index); + + list->insert(it, entity->data); + entity->data->grid = self->grid; + + if (self->grid) { + self->grid->spatial_hash.insert(entity->data); + } + + // Initialize gridstate if needed + if (entity->data->gridstate.size() == 0 && self->grid) { + entity->data->gridstate.resize(self->grid->grid_w * self->grid->grid_h); + for (auto& state : entity->data->gridstate) { + state.visible = false; + state.discovered = false; + } + } + + Py_RETURN_NONE; +} + +PyObject* UIEntityCollection::index_method(PyUIEntityCollectionObject* self, PyObject* value) { + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + if (!PyObject_IsInstance(value, (PyObject*)entity_type)) { + PyErr_SetString(PyExc_TypeError, "EntityCollection.index requires an Entity object"); + return NULL; + } + + PyUIEntityObject* entity = (PyUIEntityObject*)value; + if (!entity->data) { + PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); + return NULL; + } + + Py_ssize_t idx = 0; + for (const auto& ent : *list) { + if (ent.get() == entity->data.get()) { + return PyLong_FromSsize_t(idx); + } + idx++; + } + + PyErr_SetString(PyExc_ValueError, "Entity not in EntityCollection"); + return NULL; +} + +PyObject* UIEntityCollection::count(PyUIEntityCollectionObject* self, PyObject* value) { + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type || !PyObject_IsInstance(value, (PyObject*)entity_type)) { + return PyLong_FromLong(0); + } + + PyUIEntityObject* entity = (PyUIEntityObject*)value; + if (!entity->data) { + return PyLong_FromLong(0); + } + + Py_ssize_t cnt = 0; + for (const auto& ent : *list) { + if (ent.get() == entity->data.get()) { + cnt++; + } + } + + return PyLong_FromSsize_t(cnt); +} + +// Helper function for entity name matching with wildcards +static bool matchEntityName(const std::string& name, const std::string& pattern) { + if (pattern.find('*') != std::string::npos) { + if (pattern == "*") { + return true; + } else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) { + std::string substring = pattern.substr(1, pattern.length() - 2); + return name.find(substring) != std::string::npos; + } else if (pattern.front() == '*') { + std::string suffix = pattern.substr(1); + return name.length() >= suffix.length() && + name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0; + } else if (pattern.back() == '*') { + std::string prefix = pattern.substr(0, pattern.length() - 1); + return name.compare(0, prefix.length(), prefix) == 0; + } + return name == pattern; + } + return name == pattern; +} + +PyObject* UIEntityCollection::find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) { + const char* name = nullptr; + + static const char* kwlist[] = {"name", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(kwlist), &name)) { + return NULL; + } + + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "EntityCollection data is null"); + return NULL; + } + + std::string pattern(name); + bool has_wildcard = (pattern.find('*') != std::string::npos); + + PyTypeObject* entity_type = PyTypeCache::Entity(); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Entity type not initialized in cache"); + return NULL; + } + + if (has_wildcard) { + PyObject* results = PyList_New(0); + if (!results) { + return NULL; + } + + for (auto& entity : *list) { + if (matchEntityName(entity->sprite.name, pattern)) { + PyUIEntityObject* py_entity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!py_entity) { + Py_DECREF(results); + return NULL; + } + py_entity->data = entity; + py_entity->weakreflist = NULL; + + if (PyList_Append(results, (PyObject*)py_entity) < 0) { + Py_DECREF(py_entity); + Py_DECREF(results); + return NULL; + } + Py_DECREF(py_entity); + } + } + + return results; + } else { + for (auto& entity : *list) { + if (entity->sprite.name == pattern) { + PyUIEntityObject* py_entity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!py_entity) { + return NULL; + } + py_entity->data = entity; + py_entity->weakreflist = NULL; + return (PyObject*)py_entity; + } + } + + Py_RETURN_NONE; + } +} + +PyMethodDef UIEntityCollection::methods[] = { + {"append", (PyCFunction)UIEntityCollection::append, METH_O, + "append(entity)\n\n" + "Add an entity to the end of the collection."}, + {"extend", (PyCFunction)UIEntityCollection::extend, METH_O, + "extend(iterable)\n\n" + "Add all entities from an iterable to the collection."}, + {"insert", (PyCFunction)UIEntityCollection::insert, METH_VARARGS, + "insert(index, entity)\n\n" + "Insert entity at index. Like list.insert(), indices past the end append."}, + {"remove", (PyCFunction)UIEntityCollection::remove, METH_O, + "remove(entity)\n\n" + "Remove first occurrence of entity. Raises ValueError if not found."}, + {"pop", (PyCFunction)UIEntityCollection::pop, METH_VARARGS, + "pop([index]) -> entity\n\n" + "Remove and return entity at index (default: last entity)."}, + {"index", (PyCFunction)UIEntityCollection::index_method, METH_O, + "index(entity) -> int\n\n" + "Return index of first occurrence of entity. Raises ValueError if not found."}, + {"count", (PyCFunction)UIEntityCollection::count, METH_O, + "count(entity) -> int\n\n" + "Count occurrences of entity in the collection."}, + {"find", (PyCFunction)UIEntityCollection::find, METH_VARARGS | METH_KEYWORDS, + "find(name) -> entity or list\n\n" + "Find entities by name.\n\n" + "Args:\n" + " name (str): Name to search for. Supports wildcards:\n" + " - 'exact' for exact match (returns single entity or None)\n" + " - 'prefix*' for starts-with match (returns list)\n" + " - '*suffix' for ends-with match (returns list)\n" + " - '*substring*' for contains match (returns list)\n\n" + "Returns:\n" + " Single entity if exact match, list if wildcard, None if not found."}, + {NULL, NULL, 0, NULL} +}; + +PyObject* UIEntityCollection::repr(PyUIEntityCollectionObject* self) +{ + std::ostringstream ss; + if (!self->data) { + ss << ""; + } else { + ss << "data->size() << " entities)>"; + } + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +int UIEntityCollection::init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) +{ + PyErr_SetString(PyExc_TypeError, "EntityCollection cannot be instantiated: a C++ data source is required."); + return -1; +} + +PyObject* UIEntityCollection::iter(PyUIEntityCollectionObject* self) +{ + PyTypeObject* iterType = &mcrfpydef::PyUIEntityCollectionIterType; + + PyUIEntityCollectionIterObject* iterObj = (PyUIEntityCollectionIterObject*)iterType->tp_alloc(iterType, 0); + if (!iterObj) { + return NULL; + } + + iterObj->data = self->data; + iterObj->current = self->data->begin(); + iterObj->end = self->data->end(); + iterObj->start_size = self->data->size(); + + return (PyObject*)iterObj; +} diff --git a/src/UIEntityCollection.h b/src/UIEntityCollection.h new file mode 100644 index 0000000..f893b66 --- /dev/null +++ b/src/UIEntityCollection.h @@ -0,0 +1,145 @@ +#pragma once +// UIEntityCollection.h - Collection type for managing entities on a grid +// +// Extracted from UIGrid.cpp as part of code organization cleanup. +// This is a Python sequence/mapping type that wraps std::list> +// with grid-aware semantics (entities can only belong to one grid at a time). + +#include "Common.h" +#include "Python.h" +#include "structmember.h" +#include +#include + +// Forward declarations +class UIEntity; +class UIGrid; + +// Python object for EntityCollection +typedef struct { + PyObject_HEAD + std::shared_ptr>> data; + std::shared_ptr grid; +} PyUIEntityCollectionObject; + +// Python object for EntityCollection iterator +typedef struct { + PyObject_HEAD + std::shared_ptr>> data; + std::list>::iterator current; // Actual list iterator - O(1) increment + std::list>::iterator end; // End iterator for bounds check + int start_size; // For detecting modification during iteration +} PyUIEntityCollectionIterObject; + +// UIEntityCollection - Python collection wrapper +class UIEntityCollection { +public: + // Python sequence protocol + static PySequenceMethods sqmethods; + static PyMappingMethods mpmethods; + + // Collection methods + static PyObject* append(PyUIEntityCollectionObject* self, PyObject* o); + static PyObject* extend(PyUIEntityCollectionObject* self, PyObject* o); + static PyObject* remove(PyUIEntityCollectionObject* self, PyObject* o); + static PyObject* pop(PyUIEntityCollectionObject* self, PyObject* args); + static PyObject* insert(PyUIEntityCollectionObject* self, PyObject* args); + static PyObject* index_method(PyUIEntityCollectionObject* self, PyObject* value); + static PyObject* count(PyUIEntityCollectionObject* self, PyObject* value); + static PyObject* find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); + static PyMethodDef methods[]; + + // Python type slots + static PyObject* repr(PyUIEntityCollectionObject* self); + static int init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); + static PyObject* iter(PyUIEntityCollectionObject* self); + + // Sequence methods + static Py_ssize_t len(PyUIEntityCollectionObject* self); + static PyObject* getitem(PyUIEntityCollectionObject* self, Py_ssize_t index); + static int setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value); + static int contains(PyUIEntityCollectionObject* self, PyObject* value); + static PyObject* concat(PyUIEntityCollectionObject* self, PyObject* other); + static PyObject* inplace_concat(PyUIEntityCollectionObject* self, PyObject* other); + + // Mapping methods (for slice support) + static PyObject* subscript(PyUIEntityCollectionObject* self, PyObject* key); + static int ass_subscript(PyUIEntityCollectionObject* self, PyObject* key, PyObject* value); +}; + +// UIEntityCollectionIter - Iterator for EntityCollection +class UIEntityCollectionIter { +public: + static int init(PyUIEntityCollectionIterObject* self, PyObject* args, PyObject* kwds); + static PyObject* next(PyUIEntityCollectionIterObject* self); + static PyObject* repr(PyUIEntityCollectionIterObject* self); +}; + +// Python type objects - defined in mcrfpydef namespace +namespace mcrfpydef { + + // Iterator type + inline PyTypeObject PyUIEntityCollectionIterType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.UIEntityCollectionIter", + .tp_basicsize = sizeof(PyUIEntityCollectionIterObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)[](PyObject* self) + { + PyUIEntityCollectionIterObject* obj = (PyUIEntityCollectionIterObject*)self; + obj->data.reset(); + Py_TYPE(self)->tp_free(self); + }, + .tp_repr = (reprfunc)UIEntityCollectionIter::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Iterator for a collection of Entity objects"), + .tp_iter = PyObject_SelfIter, + .tp_iternext = (iternextfunc)UIEntityCollectionIter::next, + .tp_init = (initproc)UIEntityCollectionIter::init, + .tp_alloc = PyType_GenericAlloc, + .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* + { + PyErr_SetString(PyExc_TypeError, "UIEntityCollectionIter cannot be instantiated directly"); + return NULL; + } + }; + + // Collection type + inline PyTypeObject PyUIEntityCollectionType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.EntityCollection", + .tp_basicsize = sizeof(PyUIEntityCollectionObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)[](PyObject* self) + { + PyUIEntityCollectionObject* obj = (PyUIEntityCollectionObject*)self; + obj->data.reset(); + obj->grid.reset(); + Py_TYPE(self)->tp_free(self); + }, + .tp_repr = (reprfunc)UIEntityCollection::repr, + .tp_as_sequence = &UIEntityCollection::sqmethods, + .tp_as_mapping = &UIEntityCollection::mpmethods, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Iterable, indexable collection of Entity objects.\n\n" + "EntityCollection manages entities that belong to a Grid. " + "Entities can only belong to one grid at a time - adding an entity " + "to a new grid automatically removes it from its previous grid.\n\n" + "Supports list-like operations: indexing, slicing, append, extend, " + "remove, pop, insert, index, count, and find.\n\n" + "Example:\n" + " grid.entities.append(entity)\n" + " player = grid.entities.find(name='player')\n" + " for entity in grid.entities:\n" + " print(entity.pos)"), + .tp_iter = (getiterfunc)UIEntityCollection::iter, + .tp_methods = UIEntityCollection::methods, + .tp_init = (initproc)UIEntityCollection::init, + .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* + { + PyErr_SetString(PyExc_TypeError, "EntityCollection cannot be instantiated: a C++ data source is required."); + return NULL; + } + }; + +} // namespace mcrfpydef diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index ddf0940..35a254e 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -2,6 +2,7 @@ #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "PyTypeCache.h" // Thread-safe cached Python types #include "UIEntity.h" #include "Profiler.h" #include "PyFOV.h" @@ -12,6 +13,7 @@ #include // #150 - for strcmp #include // #169 - for std::numeric_limits // UIDrawable methods now in UIBase.h +// UIEntityCollection code moved to UIEntityCollection.cpp UIGrid::UIGrid() : grid_w(0), grid_h(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr), @@ -1210,6 +1212,54 @@ PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds) return (PyObject*)obj; } +// Grid subscript access: grid[x, y] -> GridPoint +// Enables Pythonic cell access syntax +PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key) +{ + // We expect a tuple of (x, y) + if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)"); + return NULL; + } + + PyObject* x_obj = PyTuple_GetItem(key, 0); + PyObject* y_obj = PyTuple_GetItem(key, 1); + + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be integers"); + return NULL; + } + + int x = PyLong_AsLong(x_obj); + int y = PyLong_AsLong(y_obj); + + // Range validation + if (x < 0 || x >= self->data->grid_w) { + PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w); + return NULL; + } + if (y < 0 || y >= self->data->grid_h) { + PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h); + return NULL; + } + + // Create GridPoint object (same as py_at) + auto type = &mcrfpydef::PyUIGridPointType; + auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + obj->data = &(self->data->at(x, y)); + obj->grid = self->data; + return (PyObject*)obj; +} + +// Mapping methods for grid[x, y] subscript access +PyMappingMethods UIGrid::mpmethods = { + .mp_length = NULL, // No len() for grid via mapping (use grid_w * grid_h) + .mp_subscript = (binaryfunc)UIGrid::subscript, + .mp_ass_subscript = NULL // No assignment via subscript (use grid[x,y].property = value) +}; + PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure) { auto& color = self->data->fill_color; @@ -2125,7 +2175,8 @@ PyGetSetDef UIGrid::getsetters[] = { PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure) { // Returns EntityCollection for entity management - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "EntityCollection"); + // Use the type directly from namespace (type not exported to module) + PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType; auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0); if (o) { o->data = self->data->entities; @@ -2326,992 +2377,7 @@ void UIGrid::updateCellHover(sf::Vector2f mousepos) { } } -int UIEntityCollectionIter::init(PyUIEntityCollectionIterObject* self, PyObject* args, PyObject* kwds) -{ - PyErr_SetString(PyExc_TypeError, "UICollection cannot be instantiated: a C++ data source is required."); - return -1; -} - -PyObject* UIEntityCollectionIter::next(PyUIEntityCollectionIterObject* self) -{ - // Check for collection modification during iteration - if (self->data->size() != self->start_size) - { - PyErr_SetString(PyExc_RuntimeError, "collection changed size during iteration"); - return NULL; - } - - // Check if we've reached the end - if (self->current == self->end) - { - PyErr_SetNone(PyExc_StopIteration); - return NULL; - } - - // Get current element and advance iterator - O(1) operation - auto target = *self->current; - ++self->current; - - // Return the stored Python object if it exists (preserves derived types) - if (target->self != nullptr) { - Py_INCREF(target->self); - return target->self; - } - - // Otherwise create and return a new Python Entity object - // Cache the Entity type to avoid repeated dictionary lookups (#159) - static PyTypeObject* cached_entity_type = nullptr; - if (!cached_entity_type) { - cached_entity_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - } - auto o = (PyUIEntityObject*)cached_entity_type->tp_alloc(cached_entity_type, 0); - auto p = std::static_pointer_cast(target); - o->data = p; - return (PyObject*)o; -} - -PyObject* UIEntityCollectionIter::repr(PyUIEntityCollectionIterObject* self) -{ - std::ostringstream ss; - if (!self->data) ss << ""; - else { - // Calculate current position by distance from end - auto remaining = std::distance(self->current, self->end); - auto total = self->data->size(); - ss << ""; - } - std::string repr_str = ss.str(); - return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); -} - -Py_ssize_t UIEntityCollection::len(PyUIEntityCollectionObject* self) { - return self->data->size(); -} - -PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize_t index) { - // build a Python version of item at self->data[index] - // Copy pasted:: - auto vec = self->data.get(); - if (!vec) - { - PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); - return NULL; - } - while (index < 0) index += self->data->size(); - if (index > self->data->size() - 1) - { - PyErr_SetString(PyExc_IndexError, "EntityCollection index out of range"); - return NULL; - } - auto l_begin = (*vec).begin(); - std::advance(l_begin, index); - auto target = *l_begin; //auto target = (*vec)[index]; - - // Check cache first to preserve derived class - if (target->serial_number != 0) { - PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number); - if (cached) { - return cached; // Already INCREF'd by lookup - } - } - - // Legacy: If the entity has a stored Python object reference, return that to preserve derived class - if (target->self != nullptr) { - Py_INCREF(target->self); - return target->self; - } - - // Otherwise, create a new base Entity object - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - auto o = (PyUIEntityObject*)type->tp_alloc(type, 0); - auto p = std::static_pointer_cast(target); - o->data = p; - return (PyObject*)o; -} - -int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value) { - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); - return -1; - } - - // Handle negative indexing - while (index < 0) index += list->size(); - - // Bounds check - if (index >= list->size()) { - PyErr_SetString(PyExc_IndexError, "EntityCollection assignment index out of range"); - return -1; - } - - // Get iterator to the target position - auto it = list->begin(); - std::advance(it, index); - - // Handle deletion - if (value == NULL) { - // Clear grid reference from the entity being removed - (*it)->grid = nullptr; - list->erase(it); - return 0; - } - - // Type checking - must be an Entity - if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - PyErr_SetString(PyExc_TypeError, "EntityCollection can only contain Entity objects"); - return -1; - } - - // Get the C++ object from the Python object - PyUIEntityObject* entity = (PyUIEntityObject*)value; - if (!entity->data) { - PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); - return -1; - } - - // Clear grid reference from the old entity - (*it)->grid = nullptr; - - // Replace the element and set grid reference - *it = entity->data; - entity->data->grid = self->grid; - - return 0; -} - -int UIEntityCollection::contains(PyUIEntityCollectionObject* self, PyObject* value) { - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); - return -1; - } - - // Type checking - must be an Entity - if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - // Not an Entity, so it can't be in the collection - return 0; - } - - // Get the C++ object from the Python object - PyUIEntityObject* entity = (PyUIEntityObject*)value; - if (!entity->data) { - return 0; - } - - // Search for the object by comparing C++ pointers - for (const auto& ent : *list) { - if (ent.get() == entity->data.get()) { - return 1; // Found - } - } - - return 0; // Not found -} - -PyObject* UIEntityCollection::concat(PyUIEntityCollectionObject* self, PyObject* other) { - // Create a new Python list containing elements from both collections - if (!PySequence_Check(other)) { - PyErr_SetString(PyExc_TypeError, "can only concatenate sequence to EntityCollection"); - return NULL; - } - - Py_ssize_t self_len = self->data->size(); - Py_ssize_t other_len = PySequence_Length(other); - if (other_len == -1) { - return NULL; // Error already set - } - - PyObject* result_list = PyList_New(self_len + other_len); - if (!result_list) { - return NULL; - } - - // Add all elements from self - Py_ssize_t idx = 0; - for (const auto& entity : *self->data) { - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - auto obj = (PyUIEntityObject*)type->tp_alloc(type, 0); - if (obj) { - obj->data = entity; - PyList_SET_ITEM(result_list, idx, (PyObject*)obj); // Steals reference - } else { - Py_DECREF(result_list); - Py_DECREF(type); - return NULL; - } - Py_DECREF(type); - idx++; - } - - // Add all elements from other - for (Py_ssize_t i = 0; i < other_len; i++) { - PyObject* item = PySequence_GetItem(other, i); - if (!item) { - Py_DECREF(result_list); - return NULL; - } - PyList_SET_ITEM(result_list, self_len + i, item); // Steals reference - } - - return result_list; -} - -PyObject* UIEntityCollection::inplace_concat(PyUIEntityCollectionObject* self, PyObject* other) { - if (!PySequence_Check(other)) { - PyErr_SetString(PyExc_TypeError, "can only concatenate sequence to EntityCollection"); - return NULL; - } - - // First, validate ALL items in the sequence before modifying anything - Py_ssize_t other_len = PySequence_Length(other); - if (other_len == -1) { - return NULL; // Error already set - } - - // Validate all items first - for (Py_ssize_t i = 0; i < other_len; i++) { - PyObject* item = PySequence_GetItem(other, i); - if (!item) { - return NULL; - } - - // Type check - if (!PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - Py_DECREF(item); - PyErr_Format(PyExc_TypeError, - "EntityCollection can only contain Entity objects; " - "got %s at index %zd", Py_TYPE(item)->tp_name, i); - return NULL; - } - Py_DECREF(item); - } - - // All items validated, now we can safely add them - for (Py_ssize_t i = 0; i < other_len; i++) { - PyObject* item = PySequence_GetItem(other, i); - if (!item) { - return NULL; // Shouldn't happen, but be safe - } - - // Use the existing append method which handles grid references - PyObject* result = append(self, item); - Py_DECREF(item); - - if (!result) { - return NULL; // append() failed - } - Py_DECREF(result); // append returns Py_None - } - - Py_INCREF(self); - return (PyObject*)self; -} - - -PySequenceMethods UIEntityCollection::sqmethods = { - .sq_length = (lenfunc)UIEntityCollection::len, - .sq_concat = (binaryfunc)UIEntityCollection::concat, - .sq_repeat = NULL, - .sq_item = (ssizeargfunc)UIEntityCollection::getitem, - .was_sq_slice = NULL, - .sq_ass_item = (ssizeobjargproc)UIEntityCollection::setitem, - .was_sq_ass_slice = NULL, - .sq_contains = (objobjproc)UIEntityCollection::contains, - .sq_inplace_concat = (binaryfunc)UIEntityCollection::inplace_concat, - .sq_inplace_repeat = NULL -}; - -PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject* o) -{ - // Type check - must be Entity - if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) - { - PyErr_SetString(PyExc_TypeError, "Only Entity objects can be added to EntityCollection"); - return NULL; - } - PyUIEntityObject* entity = (PyUIEntityObject*)o; - - // Remove from old grid first (if different from target grid) - // This implements the documented "single grid only" behavior - if (entity->data->grid && entity->data->grid != self->grid) { - auto old_grid = entity->data->grid; - auto& old_entities = old_grid->entities; - auto it = std::find_if(old_entities->begin(), old_entities->end(), - [entity](const std::shared_ptr& e) { - return e.get() == entity->data.get(); - }); - if (it != old_entities->end()) { - old_entities->erase(it); - // Remove from old grid's spatial hash (#115) - old_grid->spatial_hash.remove(entity->data); - } - } - - // Add to this grid (if not already in it) - if (entity->data->grid != self->grid) { - self->data->push_back(entity->data); - entity->data->grid = self->grid; - // Add to spatial hash for O(1) queries (#115) - if (self->grid) { - self->grid->spatial_hash.insert(entity->data); - } - } - - // Initialize gridstate if not already done - if (entity->data->gridstate.size() == 0 && self->grid) { - entity->data->gridstate.resize(self->grid->grid_w * self->grid->grid_h); - // Initialize all cells as not visible/discovered - for (auto& state : entity->data->gridstate) { - state.visible = false; - state.discovered = false; - } - } - - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject* o) -{ - // Type checking - must be an Entity - if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) - { - PyErr_SetString(PyExc_TypeError, "EntityCollection.remove requires an Entity object"); - return NULL; - } - - // Get the C++ object from the Python object - PyUIEntityObject* entity = (PyUIEntityObject*)o; - if (!entity->data) { - PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); - return NULL; - } - - // Get the underlying list - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "The collection store returned a null pointer"); - return NULL; - } - - // Search for the entity by comparing C++ pointers - auto it = list->begin(); - while (it != list->end()) { - if (it->get() == entity->data.get()) { - // Remove from spatial hash before clearing grid (#115) - if (self->grid) { - self->grid->spatial_hash.remove(*it); - } - - // Found it - clear grid reference before removing - (*it)->grid = nullptr; - - // Remove from the list - self->data->erase(it); - - Py_INCREF(Py_None); - return Py_None; - } - ++it; - } - - // Entity not found - raise ValueError - PyErr_SetString(PyExc_ValueError, "Entity not in EntityCollection"); - return NULL; -} - -PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject* o) -{ - // Accept any iterable of Entity objects - PyObject* iterator = PyObject_GetIter(o); - if (iterator == NULL) { - PyErr_SetString(PyExc_TypeError, "UIEntityCollection.extend requires an iterable"); - return NULL; - } - - PyObject* item; - while ((item = PyIter_Next(iterator)) != NULL) { - // Check if item is an Entity - if (!PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - Py_DECREF(item); - Py_DECREF(iterator); - PyErr_SetString(PyExc_TypeError, "All items in iterable must be Entity objects"); - return NULL; - } - - // Add the entity to the collection - PyUIEntityObject* entity = (PyUIEntityObject*)item; - self->data->push_back(entity->data); - entity->data->grid = self->grid; - - Py_DECREF(item); - } - - Py_DECREF(iterator); - - // Check if iteration ended due to an error - if (PyErr_Occurred()) { - return NULL; - } - - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* args) -{ - Py_ssize_t index = -1; // Default to last element - - if (!PyArg_ParseTuple(args, "|n", &index)) { - return NULL; - } - - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "Collection data is null"); - return NULL; - } - - if (list->empty()) { - PyErr_SetString(PyExc_IndexError, "pop from empty EntityCollection"); - return NULL; - } - - // Handle negative indexing - Py_ssize_t size = static_cast(list->size()); - if (index < 0) { - index += size; - } - - if (index < 0 || index >= size) { - PyErr_SetString(PyExc_IndexError, "pop index out of range"); - return NULL; - } - - // Navigate to the element (std::list requires iteration) - auto it = list->begin(); - std::advance(it, index); - - // Get the entity before removing - std::shared_ptr entity = *it; - - // Clear grid reference and remove from list - entity->grid = nullptr; - list->erase(it); - - // Create Python object for the entity - PyTypeObject* entityType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - if (!entityType) { - PyErr_SetString(PyExc_RuntimeError, "Could not find Entity type"); - return NULL; - } - - PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0); - Py_DECREF(entityType); - - if (!py_entity) { - return NULL; - } - - py_entity->data = entity; - py_entity->weakreflist = NULL; - - return (PyObject*)py_entity; -} - -PyObject* UIEntityCollection::insert(PyUIEntityCollectionObject* self, PyObject* args) -{ - Py_ssize_t index; - PyObject* o; - - if (!PyArg_ParseTuple(args, "nO", &index, &o)) { - return NULL; - } - - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "Collection data is null"); - return NULL; - } - - // Type checking - must be an Entity - if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - PyErr_SetString(PyExc_TypeError, "EntityCollection.insert requires an Entity object"); - return NULL; - } - - PyUIEntityObject* entity = (PyUIEntityObject*)o; - if (!entity->data) { - PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); - return NULL; - } - - // Handle negative indexing and clamping (Python list.insert behavior) - Py_ssize_t size = static_cast(list->size()); - if (index < 0) { - index += size; - if (index < 0) { - index = 0; - } - } else if (index > size) { - index = size; - } - - // Navigate to insert position - auto it = list->begin(); - std::advance(it, index); - - // Insert and set grid reference - list->insert(it, entity->data); - entity->data->grid = self->grid; - - // Initialize gridstate if needed - if (entity->data->gridstate.size() == 0 && self->grid) { - entity->data->gridstate.resize(self->grid->grid_w * self->grid->grid_h); - for (auto& state : entity->data->gridstate) { - state.visible = false; - state.discovered = false; - } - } - - Py_RETURN_NONE; -} - -PyObject* UIEntityCollection::index_method(PyUIEntityCollectionObject* self, PyObject* value) { - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); - return NULL; - } - - // Type checking - must be an Entity - if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - PyErr_SetString(PyExc_TypeError, "EntityCollection.index requires an Entity object"); - return NULL; - } - - // Get the C++ object from the Python object - PyUIEntityObject* entity = (PyUIEntityObject*)value; - if (!entity->data) { - PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); - return NULL; - } - - // Search for the object - Py_ssize_t idx = 0; - for (const auto& ent : *list) { - if (ent.get() == entity->data.get()) { - return PyLong_FromSsize_t(idx); - } - idx++; - } - - PyErr_SetString(PyExc_ValueError, "Entity not in EntityCollection"); - return NULL; -} - -PyObject* UIEntityCollection::count(PyUIEntityCollectionObject* self, PyObject* value) { - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); - return NULL; - } - - // Type checking - must be an Entity - if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - // Not an Entity, so count is 0 - return PyLong_FromLong(0); - } - - // Get the C++ object from the Python object - PyUIEntityObject* entity = (PyUIEntityObject*)value; - if (!entity->data) { - return PyLong_FromLong(0); - } - - // Count occurrences - Py_ssize_t count = 0; - for (const auto& ent : *list) { - if (ent.get() == entity->data.get()) { - count++; - } - } - - return PyLong_FromSsize_t(count); -} - -PyObject* UIEntityCollection::subscript(PyUIEntityCollectionObject* self, PyObject* key) { - if (PyLong_Check(key)) { - // Single index - delegate to sq_item - Py_ssize_t index = PyLong_AsSsize_t(key); - if (index == -1 && PyErr_Occurred()) { - return NULL; - } - return getitem(self, index); - } else if (PySlice_Check(key)) { - // Handle slice - Py_ssize_t start, stop, step, slicelength; - - if (PySlice_GetIndicesEx(key, self->data->size(), &start, &stop, &step, &slicelength) < 0) { - return NULL; - } - - PyObject* result_list = PyList_New(slicelength); - if (!result_list) { - return NULL; - } - - // Iterate through the list with slice parameters - auto it = self->data->begin(); - for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { - auto cur_it = it; - std::advance(cur_it, cur); - - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - auto obj = (PyUIEntityObject*)type->tp_alloc(type, 0); - if (obj) { - obj->data = *cur_it; - PyList_SET_ITEM(result_list, i, (PyObject*)obj); // Steals reference - } else { - Py_DECREF(result_list); - Py_DECREF(type); - return NULL; - } - Py_DECREF(type); - } - - return result_list; - } else { - PyErr_Format(PyExc_TypeError, "EntityCollection indices must be integers or slices, not %.200s", - Py_TYPE(key)->tp_name); - return NULL; - } -} - -int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject* key, PyObject* value) { - if (PyLong_Check(key)) { - // Single index - delegate to sq_ass_item - Py_ssize_t index = PyLong_AsSsize_t(key); - if (index == -1 && PyErr_Occurred()) { - return -1; - } - return setitem(self, index, value); - } else if (PySlice_Check(key)) { - // Handle slice assignment/deletion - Py_ssize_t start, stop, step, slicelength; - - if (PySlice_GetIndicesEx(key, self->data->size(), &start, &stop, &step, &slicelength) < 0) { - return -1; - } - - if (value == NULL) { - // Deletion - if (step != 1) { - // For non-contiguous slices, delete from highest to lowest to maintain indices - std::vector indices; - for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { - indices.push_back(cur); - } - // Sort in descending order - std::sort(indices.begin(), indices.end(), std::greater()); - - // Delete each index - for (Py_ssize_t idx : indices) { - auto it = self->data->begin(); - std::advance(it, idx); - (*it)->grid = nullptr; // Clear grid reference - self->data->erase(it); - } - } else { - // Contiguous slice - delete range - auto it_start = self->data->begin(); - auto it_stop = self->data->begin(); - std::advance(it_start, start); - std::advance(it_stop, stop); - - // Clear grid references - for (auto it = it_start; it != it_stop; ++it) { - (*it)->grid = nullptr; - } - - self->data->erase(it_start, it_stop); - } - return 0; - } else { - // Assignment - if (!PySequence_Check(value)) { - PyErr_SetString(PyExc_TypeError, "can only assign sequence to slice"); - return -1; - } - - Py_ssize_t value_len = PySequence_Length(value); - if (value_len == -1) { - return -1; - } - - // Validate all items first - std::vector> new_items; - for (Py_ssize_t i = 0; i < value_len; i++) { - PyObject* item = PySequence_GetItem(value, i); - if (!item) { - return -1; - } - - // Type check - if (!PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) { - Py_DECREF(item); - PyErr_Format(PyExc_TypeError, - "EntityCollection can only contain Entity objects; " - "got %s at index %zd", Py_TYPE(item)->tp_name, i); - return -1; - } - - PyUIEntityObject* entity = (PyUIEntityObject*)item; - Py_DECREF(item); - new_items.push_back(entity->data); - } - - // Now perform the assignment - if (step == 1) { - // Contiguous slice - if (slicelength != value_len) { - // Need to resize - remove old items and insert new ones - auto it_start = self->data->begin(); - auto it_stop = self->data->begin(); - std::advance(it_start, start); - std::advance(it_stop, stop); - - // Clear grid references from old items - for (auto it = it_start; it != it_stop; ++it) { - (*it)->grid = nullptr; - } - - // Erase old range - it_start = self->data->erase(it_start, it_stop); - - // Insert new items - for (const auto& entity : new_items) { - entity->grid = self->grid; - it_start = self->data->insert(it_start, entity); - ++it_start; - } - } else { - // Same size, just replace - auto it = self->data->begin(); - std::advance(it, start); - for (const auto& entity : new_items) { - (*it)->grid = nullptr; // Clear old grid ref - *it = entity; - entity->grid = self->grid; // Set new grid ref - ++it; - } - } - } else { - // Extended slice - if (slicelength != value_len) { - PyErr_Format(PyExc_ValueError, - "attempt to assign sequence of size %zd to extended slice of size %zd", - value_len, slicelength); - return -1; - } - - auto list_it = self->data->begin(); - for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { - auto cur_it = list_it; - std::advance(cur_it, cur); - (*cur_it)->grid = nullptr; // Clear old grid ref - *cur_it = new_items[i]; - new_items[i]->grid = self->grid; // Set new grid ref - } - } - - return 0; - } - } else { - PyErr_Format(PyExc_TypeError, "EntityCollection indices must be integers or slices, not %.200s", - Py_TYPE(key)->tp_name); - return -1; - } -} - -PyMappingMethods UIEntityCollection::mpmethods = { - .mp_length = (lenfunc)UIEntityCollection::len, - .mp_subscript = (binaryfunc)UIEntityCollection::subscript, - .mp_ass_subscript = (objobjargproc)UIEntityCollection::ass_subscript -}; - -// Helper function for entity name matching with wildcards -static bool matchEntityName(const std::string& name, const std::string& pattern) { - if (pattern.find('*') != std::string::npos) { - if (pattern == "*") { - return true; - } else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) { - std::string substring = pattern.substr(1, pattern.length() - 2); - return name.find(substring) != std::string::npos; - } else if (pattern.front() == '*') { - std::string suffix = pattern.substr(1); - return name.length() >= suffix.length() && - name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0; - } else if (pattern.back() == '*') { - std::string prefix = pattern.substr(0, pattern.length() - 1); - return name.compare(0, prefix.length(), prefix) == 0; - } - return name == pattern; - } - return name == pattern; -} - -PyObject* UIEntityCollection::find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) { - const char* name = nullptr; - - static const char* kwlist[] = {"name", NULL}; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(kwlist), &name)) { - return NULL; - } - - auto list = self->data.get(); - if (!list) { - PyErr_SetString(PyExc_RuntimeError, "Collection data is null"); - return NULL; - } - - std::string pattern(name); - bool has_wildcard = (pattern.find('*') != std::string::npos); - - // Get the Entity type for creating Python objects - PyTypeObject* entityType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - if (!entityType) { - PyErr_SetString(PyExc_RuntimeError, "Could not find Entity type"); - return NULL; - } - - if (has_wildcard) { - // Return list of all matches - PyObject* results = PyList_New(0); - if (!results) { - Py_DECREF(entityType); - return NULL; - } - - for (auto& entity : *list) { - // Entity name is stored in sprite.name - if (matchEntityName(entity->sprite.name, pattern)) { - PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0); - if (!py_entity) { - Py_DECREF(results); - Py_DECREF(entityType); - return NULL; - } - py_entity->data = entity; - py_entity->weakreflist = NULL; - - if (PyList_Append(results, (PyObject*)py_entity) < 0) { - Py_DECREF(py_entity); - Py_DECREF(results); - Py_DECREF(entityType); - return NULL; - } - Py_DECREF(py_entity); // PyList_Append increfs - } - } - - Py_DECREF(entityType); - return results; - } else { - // Return first exact match or None - for (auto& entity : *list) { - if (entity->sprite.name == pattern) { - PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0); - if (!py_entity) { - Py_DECREF(entityType); - return NULL; - } - py_entity->data = entity; - py_entity->weakreflist = NULL; - Py_DECREF(entityType); - return (PyObject*)py_entity; - } - } - - Py_DECREF(entityType); - Py_RETURN_NONE; - } -} - -PyMethodDef UIEntityCollection::methods[] = { - {"append", (PyCFunction)UIEntityCollection::append, METH_O, - "append(entity)\n\n" - "Add an entity to the end of the collection."}, - {"extend", (PyCFunction)UIEntityCollection::extend, METH_O, - "extend(iterable)\n\n" - "Add all entities from an iterable to the collection."}, - {"insert", (PyCFunction)UIEntityCollection::insert, METH_VARARGS, - "insert(index, entity)\n\n" - "Insert entity at index. Like list.insert(), indices past the end append."}, - {"remove", (PyCFunction)UIEntityCollection::remove, METH_O, - "remove(entity)\n\n" - "Remove first occurrence of entity. Raises ValueError if not found."}, - {"pop", (PyCFunction)UIEntityCollection::pop, METH_VARARGS, - "pop([index]) -> entity\n\n" - "Remove and return entity at index (default: last entity)."}, - {"index", (PyCFunction)UIEntityCollection::index_method, METH_O, - "index(entity) -> int\n\n" - "Return index of first occurrence of entity. Raises ValueError if not found."}, - {"count", (PyCFunction)UIEntityCollection::count, METH_O, - "count(entity) -> int\n\n" - "Count occurrences of entity in the collection."}, - {"find", (PyCFunction)UIEntityCollection::find, METH_VARARGS | METH_KEYWORDS, - "find(name) -> entity or list\n\n" - "Find entities by name.\n\n" - "Args:\n" - " name (str): Name to search for. Supports wildcards:\n" - " - 'exact' for exact match (returns single entity or None)\n" - " - 'prefix*' for starts-with match (returns list)\n" - " - '*suffix' for ends-with match (returns list)\n" - " - '*substring*' for contains match (returns list)\n\n" - "Returns:\n" - " Single entity if exact match, list if wildcard, None if not found."}, - {NULL, NULL, 0, NULL} -}; - -PyObject* UIEntityCollection::repr(PyUIEntityCollectionObject* self) -{ - std::ostringstream ss; - if (!self->data) ss << ""; - else { - ss << "data->size() << " child objects)>"; - } - std::string repr_str = ss.str(); - return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); -} - -int UIEntityCollection::init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) -{ - PyErr_SetString(PyExc_TypeError, "EntityCollection cannot be instantiated: a C++ data source is required."); - return -1; -} - -PyObject* UIEntityCollection::iter(PyUIEntityCollectionObject* self) -{ - // Use the iterator type directly from namespace (#189 - type not exported to module) - PyTypeObject* iterType = &mcrfpydef::PyUIEntityCollectionIterType; - - // Allocate new iterator instance - PyUIEntityCollectionIterObject* iterObj = (PyUIEntityCollectionIterObject*)iterType->tp_alloc(iterType, 0); - - if (iterObj == NULL) { - return NULL; // Failed to allocate memory for the iterator object - } - - iterObj->data = self->data; - iterObj->current = self->data->begin(); // Start at beginning - iterObj->end = self->data->end(); // Cache end iterator - iterObj->start_size = self->data->size(); - - return (PyObject*)iterObj; -} +// UIEntityCollection code has been moved to UIEntityCollection.cpp // Property system implementation for animations bool UIGrid::setProperty(const std::string& name, float value) { diff --git a/src/UIGrid.h b/src/UIGrid.h index bb707f2..b8a9d2d 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -23,6 +23,7 @@ #include "GridLayers.h" #include "GridChunk.h" #include "SpatialHash.h" +#include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid) class UIGrid: public UIDrawable { @@ -180,6 +181,8 @@ public: static PyMethodDef methods[]; static PyGetSetDef getsetters[]; + static PyMappingMethods mpmethods; // For grid[x, y] subscript access + static PyObject* subscript(PyUIGridObject* self, PyObject* key); // __getitem__ static PyObject* get_entities(PyUIGridObject* self, void* closure); static PyObject* get_children(PyUIGridObject* self, void* closure); static PyObject* repr(PyUIGridObject* self); @@ -200,54 +203,7 @@ public: static PyObject* py_layer(PyUIGridObject* self, PyObject* args); }; -typedef struct { - PyObject_HEAD - std::shared_ptr>> data; - std::shared_ptr grid; -} PyUIEntityCollectionObject; - -class UIEntityCollection { -public: - static PySequenceMethods sqmethods; - static PyMappingMethods mpmethods; - static PyObject* append(PyUIEntityCollectionObject* self, PyObject* o); - static PyObject* extend(PyUIEntityCollectionObject* self, PyObject* o); - static PyObject* remove(PyUIEntityCollectionObject* self, PyObject* o); - static PyObject* pop(PyUIEntityCollectionObject* self, PyObject* args); - static PyObject* insert(PyUIEntityCollectionObject* self, PyObject* args); - static PyObject* index_method(PyUIEntityCollectionObject* self, PyObject* value); - static PyObject* count(PyUIEntityCollectionObject* self, PyObject* value); - static PyObject* find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); - static PyMethodDef methods[]; - static PyObject* repr(PyUIEntityCollectionObject* self); - static int init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); - static PyObject* iter(PyUIEntityCollectionObject* self); - static Py_ssize_t len(PyUIEntityCollectionObject* self); - static PyObject* getitem(PyUIEntityCollectionObject* self, Py_ssize_t index); - static int setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value); - static int contains(PyUIEntityCollectionObject* self, PyObject* value); - static PyObject* concat(PyUIEntityCollectionObject* self, PyObject* other); - static PyObject* inplace_concat(PyUIEntityCollectionObject* self, PyObject* other); - static PyObject* subscript(PyUIEntityCollectionObject* self, PyObject* key); - static int ass_subscript(PyUIEntityCollectionObject* self, PyObject* key, PyObject* value); -}; - -typedef struct { - PyObject_HEAD - std::shared_ptr>> data; - std::list>::iterator current; // Actual list iterator - O(1) increment - std::list>::iterator end; // End iterator for bounds check - int start_size; // For detecting modification during iteration -} PyUIEntityCollectionIterObject; - -class UIEntityCollectionIter { -public: - static int init(PyUIEntityCollectionIterObject* self, PyObject* args, PyObject* kwds); - static PyObject* next(PyUIEntityCollectionIterObject* self); - static PyObject* repr(PyUIEntityCollectionIterObject* self); - static PyObject* getitem(PyUIEntityCollectionObject* self, Py_ssize_t index); - -}; +// UIEntityCollection types are now in UIEntityCollection.h // Forward declaration of methods array extern PyMethodDef UIGrid_all_methods[]; @@ -268,11 +224,8 @@ namespace mcrfpydef { obj->data.reset(); Py_TYPE(self)->tp_free(self); }, - //TODO - PyUIGrid REPR def: .tp_repr = (reprfunc)UIGrid::repr, - //.tp_hash = NULL, - //.tp_iter - //.tp_iternext + .tp_as_mapping = &UIGrid::mpmethods, // Enable grid[x, y] subscript access .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, .tp_doc = PyDoc_STR("Grid(pos=None, size=None, grid_size=None, texture=None, **kwargs)\n\n" "A grid-based UI element for tile-based rendering and entity management.\n\n" @@ -330,61 +283,6 @@ namespace mcrfpydef { } }; - // #189 - Use inline instead of static to ensure single instance across translation units - inline PyTypeObject PyUIEntityCollectionIterType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.UIEntityCollectionIter", - .tp_basicsize = sizeof(PyUIEntityCollectionIterObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)[](PyObject* self) - { - PyUIEntityCollectionIterObject* obj = (PyUIEntityCollectionIterObject*)self; - obj->data.reset(); - Py_TYPE(self)->tp_free(self); - }, - .tp_repr = (reprfunc)UIEntityCollectionIter::repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("Iterator for a collection of UI objects"), - .tp_iter = PyObject_SelfIter, - .tp_iternext = (iternextfunc)UIEntityCollectionIter::next, - //.tp_getset = UIEntityCollection::getset, - .tp_init = (initproc)UIEntityCollectionIter::init, // just raise an exception - .tp_alloc = PyType_GenericAlloc, - .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* - { - PyErr_SetString(PyExc_TypeError, "UICollection cannot be instantiated: a C++ data source is required."); - return NULL; - } - }; - - // #189 - Use inline instead of static to ensure single instance across translation units - inline PyTypeObject PyUIEntityCollectionType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.EntityCollection", - .tp_basicsize = sizeof(PyUIEntityCollectionObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)[](PyObject* self) - { - PyUIEntityCollectionObject* obj = (PyUIEntityCollectionObject*)self; - obj->data.reset(); - Py_TYPE(self)->tp_free(self); - }, - .tp_repr = (reprfunc)UIEntityCollection::repr, - .tp_as_sequence = &UIEntityCollection::sqmethods, - .tp_as_mapping = &UIEntityCollection::mpmethods, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("Iterable, indexable collection of Entities"), - .tp_iter = (getiterfunc)UIEntityCollection::iter, - .tp_methods = UIEntityCollection::methods, // append, remove - //.tp_getset = UIEntityCollection::getset, - .tp_init = (initproc)UIEntityCollection::init, // just raise an exception - .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* - { - // Does PyUIEntityCollectionType need __new__ if it's not supposed to be instantiable by the user? - // Should I just raise an exception? Or is the uninitialized shared_ptr enough of a blocker? - PyErr_SetString(PyExc_TypeError, "EntityCollection cannot be instantiated: a C++ data source is required."); - return NULL; - } - }; + // EntityCollection types moved to UIEntityCollection.h } From d9411f94a43bd3aa7b8acad931ca0325a29aeb51 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 10 Jan 2026 08:55:50 -0500 Subject: [PATCH 2/3] Version bump: 0.2.0-prerelease-7drl2026 (d6ef29f) -> 0.2.1-prerelease-7drl2026 --- src/McRogueFaceVersion.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/McRogueFaceVersion.h b/src/McRogueFaceVersion.h index ba5f091..9051aeb 100644 --- a/src/McRogueFaceVersion.h +++ b/src/McRogueFaceVersion.h @@ -1,4 +1,4 @@ #pragma once // McRogueFace version string (#164) -#define MCRFPY_VERSION "0.2.0-prerelease-7drl2026" +#define MCRFPY_VERSION "0.2.1-prerelease-7drl2026" From 9eacedc624b26b40063fd6259fb71da3fbe948f5 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 10 Jan 2026 21:31:20 -0500 Subject: [PATCH 3/3] Input Enums instead of strings. --- src/McRFPy_API.cpp | 24 +++ src/PyInputState.cpp | 187 ++++++++++++++++++ src/PyInputState.h | 34 ++++ src/PyKey.cpp | 335 +++++++++++++++++++++++++++++++++ src/PyKey.h | 44 +++++ src/PyMouseButton.cpp | 205 ++++++++++++++++++++ src/PyMouseButton.h | 37 ++++ stubs/mcrfpy.pyi | 201 ++++++++++++++++++++ tests/unit/test_input_enums.py | 138 ++++++++++++++ 9 files changed, 1205 insertions(+) create mode 100644 src/PyInputState.cpp create mode 100644 src/PyInputState.h create mode 100644 src/PyKey.cpp create mode 100644 src/PyKey.h create mode 100644 src/PyMouseButton.cpp create mode 100644 src/PyMouseButton.h create mode 100644 tests/unit/test_input_enums.py diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 5bf0400..e0dc9de 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -13,6 +13,9 @@ #include "PyFOV.h" #include "PyTransition.h" #include "PyEasing.h" +#include "PyKey.h" +#include "PyMouseButton.h" +#include "PyInputState.h" #include "PySound.h" #include "PyMusic.h" #include "PyKeyboard.h" @@ -536,6 +539,27 @@ PyObject* PyInit_mcrfpy() PyErr_Clear(); } + // Add Key enum class for keyboard input + PyObject* key_class = PyKey::create_enum_class(m); + if (!key_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + + // Add MouseButton enum class for mouse input + PyObject* mouse_button_class = PyMouseButton::create_enum_class(m); + if (!mouse_button_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + + // Add InputState enum class for input event states (pressed/released) + PyObject* input_state_class = PyInputState::create_enum_class(m); + if (!input_state_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + // Add automation submodule PyObject* automation_module = McRFPy_Automation::init_automation_module(); if (automation_module != NULL) { diff --git a/src/PyInputState.cpp b/src/PyInputState.cpp new file mode 100644 index 0000000..ea8fe55 --- /dev/null +++ b/src/PyInputState.cpp @@ -0,0 +1,187 @@ +#include "PyInputState.h" +#include + +// Static storage for cached enum class reference +PyObject* PyInputState::input_state_enum_class = nullptr; + +// InputState entries - maps enum name to value and legacy string +struct InputStateEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value + const char* legacy; // Legacy string name for backwards compatibility +}; + +static const InputStateEntry input_state_table[] = { + {"PRESSED", 0, "start"}, + {"RELEASED", 1, "end"}, +}; + +static const int NUM_INPUT_STATE_ENTRIES = sizeof(input_state_table) / sizeof(input_state_table[0]); + +const char* PyInputState::to_legacy_string(bool pressed) { + return pressed ? "start" : "end"; +} + +PyObject* PyInputState::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class InputState(IntEnum):\n"; + code << " \"\"\"Enum representing input event states (pressed/released).\n"; + code << " \n"; + code << " Values:\n"; + code << " PRESSED: Key or button was pressed (legacy: 'start')\n"; + code << " RELEASED: Key or button was released (legacy: 'end')\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " InputState.PRESSED == 'start' # True\n"; + code << " InputState.RELEASED == 'end' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + code << " " << input_state_table[i].name << " = " << input_state_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "InputState._legacy_names = {\n"; + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + code << " " << input_state_table[i].value << ": \"" << input_state_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _InputState_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "PRESSED") + if self.name == other: + return True + # Check legacy name match (e.g., "start") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +InputState.__eq__ = _InputState_eq +InputState.__hash__ = lambda self: hash(int(self)) +InputState.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +InputState.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + 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; + } + + // Execute the code to create the enum + 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); + + // Get the InputState class from locals + PyObject* input_state_class = PyDict_GetItemString(locals, "InputState"); + if (!input_state_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create InputState enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(input_state_class); + + // Cache the reference for fast type checking + input_state_enum_class = input_state_class; + Py_INCREF(input_state_enum_class); + + // Add to module + if (PyModule_AddObject(module, "InputState", input_state_class) < 0) { + Py_DECREF(input_state_class); + Py_DECREF(globals); + Py_DECREF(locals); + input_state_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return input_state_class; +} + +int PyInputState::from_arg(PyObject* arg, bool* out_pressed) { + // Accept InputState enum member + if (input_state_enum_class && PyObject_IsInstance(arg, input_state_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; + } + *out_pressed = (val == 0); // PRESSED = 0 + return 1; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val == 0 || val == 1) { + *out_pressed = (val == 0); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid InputState value: %ld. Must be 0 (PRESSED) or 1 (RELEASED).", val); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + if (strcmp(name, input_state_table[i].name) == 0 || + strcmp(name, input_state_table[i].legacy) == 0) { + *out_pressed = (input_state_table[i].value == 0); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown InputState: '%s'. Use InputState.PRESSED, InputState.RELEASED, " + "or legacy strings 'start', 'end'.", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "InputState must be mcrfpy.InputState enum member, string, or int"); + return 0; +} diff --git a/src/PyInputState.h b/src/PyInputState.h new file mode 100644 index 0000000..971cdbd --- /dev/null +++ b/src/PyInputState.h @@ -0,0 +1,34 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +// Module-level InputState enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.InputState +// +// Values: +// PRESSED = 0 (corresponds to "start" in legacy API) +// RELEASED = 1 (corresponds to "end" in legacy API) +// +// The enum compares equal to both its name ("PRESSED") and legacy string ("start") + +class PyInputState { +public: + // Create the InputState 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 input state from Python arg + // Accepts InputState enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + // out_pressed is set to true for PRESSED/start, false for RELEASED/end + static int from_arg(PyObject* arg, bool* out_pressed); + + // Convert bool to legacy string name (for passing to callbacks) + static const char* to_legacy_string(bool pressed); + + // Cached reference to the InputState enum class for fast type checking + static PyObject* input_state_enum_class; + + // Number of input states + static const int NUM_INPUT_STATES = 2; +}; diff --git a/src/PyKey.cpp b/src/PyKey.cpp new file mode 100644 index 0000000..8cdefb3 --- /dev/null +++ b/src/PyKey.cpp @@ -0,0 +1,335 @@ +#include "PyKey.h" +#include +#include + +// Static storage for cached enum class reference +PyObject* PyKey::key_enum_class = nullptr; + +// Key entries - maps enum name to SFML value and legacy string +struct KeyEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value (matches sf::Keyboard::Key) + const char* legacy; // Legacy string name for backwards compatibility +}; + +// Complete key table matching SFML's sf::Keyboard::Key enum +static const KeyEntry key_table[] = { + // Letters (sf::Keyboard::A = 0 through sf::Keyboard::Z = 25) + {"A", sf::Keyboard::A, "A"}, + {"B", sf::Keyboard::B, "B"}, + {"C", sf::Keyboard::C, "C"}, + {"D", sf::Keyboard::D, "D"}, + {"E", sf::Keyboard::E, "E"}, + {"F", sf::Keyboard::F, "F"}, + {"G", sf::Keyboard::G, "G"}, + {"H", sf::Keyboard::H, "H"}, + {"I", sf::Keyboard::I, "I"}, + {"J", sf::Keyboard::J, "J"}, + {"K", sf::Keyboard::K, "K"}, + {"L", sf::Keyboard::L, "L"}, + {"M", sf::Keyboard::M, "M"}, + {"N", sf::Keyboard::N, "N"}, + {"O", sf::Keyboard::O, "O"}, + {"P", sf::Keyboard::P, "P"}, + {"Q", sf::Keyboard::Q, "Q"}, + {"R", sf::Keyboard::R, "R"}, + {"S", sf::Keyboard::S, "S"}, + {"T", sf::Keyboard::T, "T"}, + {"U", sf::Keyboard::U, "U"}, + {"V", sf::Keyboard::V, "V"}, + {"W", sf::Keyboard::W, "W"}, + {"X", sf::Keyboard::X, "X"}, + {"Y", sf::Keyboard::Y, "Y"}, + {"Z", sf::Keyboard::Z, "Z"}, + + // Number row (sf::Keyboard::Num0 = 26 through Num9 = 35) + {"NUM_0", sf::Keyboard::Num0, "Num0"}, + {"NUM_1", sf::Keyboard::Num1, "Num1"}, + {"NUM_2", sf::Keyboard::Num2, "Num2"}, + {"NUM_3", sf::Keyboard::Num3, "Num3"}, + {"NUM_4", sf::Keyboard::Num4, "Num4"}, + {"NUM_5", sf::Keyboard::Num5, "Num5"}, + {"NUM_6", sf::Keyboard::Num6, "Num6"}, + {"NUM_7", sf::Keyboard::Num7, "Num7"}, + {"NUM_8", sf::Keyboard::Num8, "Num8"}, + {"NUM_9", sf::Keyboard::Num9, "Num9"}, + + // Control keys + {"ESCAPE", sf::Keyboard::Escape, "Escape"}, + {"LEFT_CONTROL", sf::Keyboard::LControl, "LControl"}, + {"LEFT_SHIFT", sf::Keyboard::LShift, "LShift"}, + {"LEFT_ALT", sf::Keyboard::LAlt, "LAlt"}, + {"LEFT_SYSTEM", sf::Keyboard::LSystem, "LSystem"}, + {"RIGHT_CONTROL", sf::Keyboard::RControl, "RControl"}, + {"RIGHT_SHIFT", sf::Keyboard::RShift, "RShift"}, + {"RIGHT_ALT", sf::Keyboard::RAlt, "RAlt"}, + {"RIGHT_SYSTEM", sf::Keyboard::RSystem, "RSystem"}, + {"MENU", sf::Keyboard::Menu, "Menu"}, + + // Punctuation and symbols + {"LEFT_BRACKET", sf::Keyboard::LBracket, "LBracket"}, + {"RIGHT_BRACKET", sf::Keyboard::RBracket, "RBracket"}, + {"SEMICOLON", sf::Keyboard::Semicolon, "Semicolon"}, + {"COMMA", sf::Keyboard::Comma, "Comma"}, + {"PERIOD", sf::Keyboard::Period, "Period"}, + {"APOSTROPHE", sf::Keyboard::Apostrophe, "Apostrophe"}, + {"SLASH", sf::Keyboard::Slash, "Slash"}, + {"BACKSLASH", sf::Keyboard::Backslash, "Backslash"}, + {"GRAVE", sf::Keyboard::Grave, "Grave"}, + {"EQUAL", sf::Keyboard::Equal, "Equal"}, + {"HYPHEN", sf::Keyboard::Hyphen, "Hyphen"}, + + // Whitespace and editing + {"SPACE", sf::Keyboard::Space, "Space"}, + {"ENTER", sf::Keyboard::Enter, "Enter"}, + {"BACKSPACE", sf::Keyboard::Backspace, "Backspace"}, + {"TAB", sf::Keyboard::Tab, "Tab"}, + + // Navigation + {"PAGE_UP", sf::Keyboard::PageUp, "PageUp"}, + {"PAGE_DOWN", sf::Keyboard::PageDown, "PageDown"}, + {"END", sf::Keyboard::End, "End"}, + {"HOME", sf::Keyboard::Home, "Home"}, + {"INSERT", sf::Keyboard::Insert, "Insert"}, + {"DELETE", sf::Keyboard::Delete, "Delete"}, + + // Numpad operators + {"ADD", sf::Keyboard::Add, "Add"}, + {"SUBTRACT", sf::Keyboard::Subtract, "Subtract"}, + {"MULTIPLY", sf::Keyboard::Multiply, "Multiply"}, + {"DIVIDE", sf::Keyboard::Divide, "Divide"}, + + // Arrow keys + {"LEFT", sf::Keyboard::Left, "Left"}, + {"RIGHT", sf::Keyboard::Right, "Right"}, + {"UP", sf::Keyboard::Up, "Up"}, + {"DOWN", sf::Keyboard::Down, "Down"}, + + // Numpad numbers (sf::Keyboard::Numpad0 = 75 through Numpad9 = 84) + {"NUMPAD_0", sf::Keyboard::Numpad0, "Numpad0"}, + {"NUMPAD_1", sf::Keyboard::Numpad1, "Numpad1"}, + {"NUMPAD_2", sf::Keyboard::Numpad2, "Numpad2"}, + {"NUMPAD_3", sf::Keyboard::Numpad3, "Numpad3"}, + {"NUMPAD_4", sf::Keyboard::Numpad4, "Numpad4"}, + {"NUMPAD_5", sf::Keyboard::Numpad5, "Numpad5"}, + {"NUMPAD_6", sf::Keyboard::Numpad6, "Numpad6"}, + {"NUMPAD_7", sf::Keyboard::Numpad7, "Numpad7"}, + {"NUMPAD_8", sf::Keyboard::Numpad8, "Numpad8"}, + {"NUMPAD_9", sf::Keyboard::Numpad9, "Numpad9"}, + + // Function keys (sf::Keyboard::F1 = 85 through F15 = 99) + {"F1", sf::Keyboard::F1, "F1"}, + {"F2", sf::Keyboard::F2, "F2"}, + {"F3", sf::Keyboard::F3, "F3"}, + {"F4", sf::Keyboard::F4, "F4"}, + {"F5", sf::Keyboard::F5, "F5"}, + {"F6", sf::Keyboard::F6, "F6"}, + {"F7", sf::Keyboard::F7, "F7"}, + {"F8", sf::Keyboard::F8, "F8"}, + {"F9", sf::Keyboard::F9, "F9"}, + {"F10", sf::Keyboard::F10, "F10"}, + {"F11", sf::Keyboard::F11, "F11"}, + {"F12", sf::Keyboard::F12, "F12"}, + {"F13", sf::Keyboard::F13, "F13"}, + {"F14", sf::Keyboard::F14, "F14"}, + {"F15", sf::Keyboard::F15, "F15"}, + + // Misc + {"PAUSE", sf::Keyboard::Pause, "Pause"}, + + // Unknown key (for completeness) + {"UNKNOWN", sf::Keyboard::Unknown, "Unknown"}, +}; + +static const int NUM_KEY_ENTRIES = sizeof(key_table) / sizeof(key_table[0]); + +const char* PyKey::to_legacy_string(sf::Keyboard::Key key) { + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (key_table[i].value == static_cast(key)) { + return key_table[i].legacy; + } + } + return "Unknown"; +} + +sf::Keyboard::Key PyKey::from_legacy_string(const char* name) { + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (strcmp(key_table[i].legacy, name) == 0 || + strcmp(key_table[i].name, name) == 0) { + return static_cast(key_table[i].value); + } + } + return sf::Keyboard::Unknown; +} + +PyObject* PyKey::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class Key(IntEnum):\n"; + code << " \"\"\"Enum representing keyboard keys.\n"; + code << " \n"; + code << " Values map to SFML's sf::Keyboard::Key enum.\n"; + code << " \n"; + code << " Categories:\n"; + code << " Letters: A-Z\n"; + code << " Numbers: NUM_0 through NUM_9 (top row)\n"; + code << " Numpad: NUMPAD_0 through NUMPAD_9\n"; + code << " Function: F1 through F15\n"; + code << " Modifiers: LEFT_SHIFT, RIGHT_SHIFT, LEFT_CONTROL, etc.\n"; + code << " Navigation: LEFT, RIGHT, UP, DOWN, HOME, END, PAGE_UP, PAGE_DOWN\n"; + code << " Editing: ENTER, BACKSPACE, DELETE, INSERT, TAB, SPACE\n"; + code << " Symbols: COMMA, PERIOD, SLASH, SEMICOLON, etc.\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " Key.ESCAPE == 'Escape' # True\n"; + code << " Key.LEFT_SHIFT == 'LShift' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + code << " " << key_table[i].name << " = " << key_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "Key._legacy_names = {\n"; + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + code << " " << key_table[i].value << ": \"" << key_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _Key_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "ESCAPE") + if self.name == other: + return True + # Check legacy name match (e.g., "Escape") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +Key.__eq__ = _Key_eq +Key.__hash__ = lambda self: hash(int(self)) +Key.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +Key.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + 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; + } + + // Execute the code to create the enum + 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); + + // Get the Key class from locals + PyObject* key_class = PyDict_GetItemString(locals, "Key"); + if (!key_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create Key enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(key_class); + + // Cache the reference for fast type checking + key_enum_class = key_class; + Py_INCREF(key_enum_class); + + // Add to module + if (PyModule_AddObject(module, "Key", key_class) < 0) { + Py_DECREF(key_class); + Py_DECREF(globals); + Py_DECREF(locals); + key_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return key_class; +} + +int PyKey::from_arg(PyObject* arg, sf::Keyboard::Key* out_key) { + // Accept Key enum member + if (key_enum_class && PyObject_IsInstance(arg, key_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; + } + *out_key = static_cast(val); + return 1; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= -1 && val < sf::Keyboard::KeyCount) { + *out_key = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid Key value: %ld. Must be -1 (Unknown) to %d.", val, sf::Keyboard::KeyCount - 1); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (strcmp(name, key_table[i].name) == 0 || + strcmp(name, key_table[i].legacy) == 0) { + *out_key = static_cast(key_table[i].value); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown Key: '%s'. Use Key enum members (e.g., Key.ESCAPE, Key.A) " + "or legacy strings (e.g., 'Escape', 'A').", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "Key must be mcrfpy.Key enum member, string, or int"); + return 0; +} diff --git a/src/PyKey.h b/src/PyKey.h new file mode 100644 index 0000000..354b36e --- /dev/null +++ b/src/PyKey.h @@ -0,0 +1,44 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include + +// Module-level Key enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.Key +// +// Values map to sf::Keyboard::Key enum values. +// The enum compares equal to both its name ("ESCAPE") and legacy string ("Escape") +// +// Naming convention: +// - Letters: A, B, C, ... Z +// - Numbers: NUM_0, NUM_1, ... NUM_9 (top row) +// - Numpad: NUMPAD_0, NUMPAD_1, ... NUMPAD_9 +// - Function keys: F1, F2, ... F15 +// - Modifiers: LEFT_SHIFT, RIGHT_SHIFT, LEFT_CONTROL, RIGHT_CONTROL, etc. +// - Navigation: LEFT, RIGHT, UP, DOWN, HOME, END, PAGE_UP, PAGE_DOWN +// - Special: ESCAPE, ENTER, SPACE, TAB, BACKSPACE, DELETE, INSERT, PAUSE + +class PyKey { +public: + // Create the Key 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 key from Python arg + // Accepts Key enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + static int from_arg(PyObject* arg, sf::Keyboard::Key* out_key); + + // Convert sf::Keyboard::Key to legacy string name (for passing to callbacks) + static const char* to_legacy_string(sf::Keyboard::Key key); + + // Convert legacy string to sf::Keyboard::Key + // Returns sf::Keyboard::Unknown if not found + static sf::Keyboard::Key from_legacy_string(const char* name); + + // Cached reference to the Key enum class for fast type checking + static PyObject* key_enum_class; + + // Number of keys (matches sf::Keyboard::KeyCount) + static const int NUM_KEYS = sf::Keyboard::KeyCount; +}; diff --git a/src/PyMouseButton.cpp b/src/PyMouseButton.cpp new file mode 100644 index 0000000..ca80fb6 --- /dev/null +++ b/src/PyMouseButton.cpp @@ -0,0 +1,205 @@ +#include "PyMouseButton.h" +#include + +// Static storage for cached enum class reference +PyObject* PyMouseButton::mouse_button_enum_class = nullptr; + +// MouseButton entries - maps enum name to value and legacy string +struct MouseButtonEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value (matches sf::Mouse::Button) + const char* legacy; // Legacy string name for backwards compatibility +}; + +static const MouseButtonEntry mouse_button_table[] = { + {"LEFT", sf::Mouse::Left, "left"}, + {"RIGHT", sf::Mouse::Right, "right"}, + {"MIDDLE", sf::Mouse::Middle, "middle"}, + {"X1", sf::Mouse::XButton1, "x1"}, + {"X2", sf::Mouse::XButton2, "x2"}, +}; + +static const int NUM_MOUSE_BUTTON_ENTRIES = sizeof(mouse_button_table) / sizeof(mouse_button_table[0]); + +const char* PyMouseButton::to_legacy_string(sf::Mouse::Button button) { + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + if (mouse_button_table[i].value == static_cast(button)) { + return mouse_button_table[i].legacy; + } + } + return "left"; // Default fallback +} + +PyObject* PyMouseButton::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class MouseButton(IntEnum):\n"; + code << " \"\"\"Enum representing mouse buttons.\n"; + code << " \n"; + code << " Values:\n"; + code << " LEFT: Left mouse button (legacy: 'left')\n"; + code << " RIGHT: Right mouse button (legacy: 'right')\n"; + code << " MIDDLE: Middle mouse button / scroll wheel click (legacy: 'middle')\n"; + code << " X1: Extra mouse button 1 (legacy: 'x1')\n"; + code << " X2: Extra mouse button 2 (legacy: 'x2')\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " MouseButton.LEFT == 'left' # True\n"; + code << " MouseButton.RIGHT == 'right' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + code << " " << mouse_button_table[i].name << " = " << mouse_button_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "MouseButton._legacy_names = {\n"; + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + code << " " << mouse_button_table[i].value << ": \"" << mouse_button_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _MouseButton_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "LEFT") + if self.name == other: + return True + # Check legacy name match (e.g., "left") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +MouseButton.__eq__ = _MouseButton_eq +MouseButton.__hash__ = lambda self: hash(int(self)) +MouseButton.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +MouseButton.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + 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; + } + + // Execute the code to create the enum + 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); + + // Get the MouseButton class from locals + PyObject* mouse_button_class = PyDict_GetItemString(locals, "MouseButton"); + if (!mouse_button_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create MouseButton enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(mouse_button_class); + + // Cache the reference for fast type checking + mouse_button_enum_class = mouse_button_class; + Py_INCREF(mouse_button_enum_class); + + // Add to module + if (PyModule_AddObject(module, "MouseButton", mouse_button_class) < 0) { + Py_DECREF(mouse_button_class); + Py_DECREF(globals); + Py_DECREF(locals); + mouse_button_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return mouse_button_class; +} + +int PyMouseButton::from_arg(PyObject* arg, sf::Mouse::Button* out_button) { + // Accept MouseButton enum member + if (mouse_button_enum_class && PyObject_IsInstance(arg, mouse_button_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 < NUM_MOUSE_BUTTON_ENTRIES) { + *out_button = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid MouseButton value: %ld. Must be 0-4.", val); + return 0; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_MOUSE_BUTTON_ENTRIES) { + *out_button = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid MouseButton value: %ld. Must be 0 (LEFT), 1 (RIGHT), 2 (MIDDLE), " + "3 (X1), or 4 (X2).", val); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + if (strcmp(name, mouse_button_table[i].name) == 0 || + strcmp(name, mouse_button_table[i].legacy) == 0) { + *out_button = static_cast(mouse_button_table[i].value); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown MouseButton: '%s'. Use MouseButton.LEFT, MouseButton.RIGHT, " + "MouseButton.MIDDLE, MouseButton.X1, MouseButton.X2, " + "or legacy strings 'left', 'right', 'middle', 'x1', 'x2'.", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "MouseButton must be mcrfpy.MouseButton enum member, string, or int"); + return 0; +} diff --git a/src/PyMouseButton.h b/src/PyMouseButton.h new file mode 100644 index 0000000..743a9b8 --- /dev/null +++ b/src/PyMouseButton.h @@ -0,0 +1,37 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include + +// Module-level MouseButton enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.MouseButton +// +// Values map to sf::Mouse::Button: +// LEFT = 0 (corresponds to "left" in legacy API) +// RIGHT = 1 (corresponds to "right" in legacy API) +// MIDDLE = 2 (corresponds to "middle" in legacy API) +// X1 = 3 (extra button 1) +// X2 = 4 (extra button 2) +// +// The enum compares equal to both its name ("LEFT") and legacy string ("left") + +class PyMouseButton { +public: + // Create the MouseButton 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 mouse button from Python arg + // Accepts MouseButton enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + static int from_arg(PyObject* arg, sf::Mouse::Button* out_button); + + // Convert sf::Mouse::Button to legacy string name (for passing to callbacks) + static const char* to_legacy_string(sf::Mouse::Button button); + + // Cached reference to the MouseButton enum class for fast type checking + static PyObject* mouse_button_enum_class; + + // Number of mouse buttons + static const int NUM_MOUSE_BUTTONS = 5; +}; diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index 2a890fa..258e848 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -4,11 +4,212 @@ Core game engine interface for creating roguelike games with Python. """ from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload +from enum import IntEnum # Type aliases UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid', 'Line', 'Circle', 'Arc'] Transition = Union[str, None] +# Enums + +class Key(IntEnum): + """Keyboard key codes. + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + Key.ESCAPE == 'Escape' # True + Key.LEFT_SHIFT == 'LShift' # True + """ + # Letters + A = 0 + B = 1 + C = 2 + D = 3 + E = 4 + F = 5 + G = 6 + H = 7 + I = 8 + J = 9 + K = 10 + L = 11 + M = 12 + N = 13 + O = 14 + P = 15 + Q = 16 + R = 17 + S = 18 + T = 19 + U = 20 + V = 21 + W = 22 + X = 23 + Y = 24 + Z = 25 + # Number row + NUM_0 = 26 + NUM_1 = 27 + NUM_2 = 28 + NUM_3 = 29 + NUM_4 = 30 + NUM_5 = 31 + NUM_6 = 32 + NUM_7 = 33 + NUM_8 = 34 + NUM_9 = 35 + # Control keys + ESCAPE = 36 + LEFT_CONTROL = 37 + LEFT_SHIFT = 38 + LEFT_ALT = 39 + LEFT_SYSTEM = 40 + RIGHT_CONTROL = 41 + RIGHT_SHIFT = 42 + RIGHT_ALT = 43 + RIGHT_SYSTEM = 44 + MENU = 45 + # Punctuation + LEFT_BRACKET = 46 + RIGHT_BRACKET = 47 + SEMICOLON = 48 + COMMA = 49 + PERIOD = 50 + APOSTROPHE = 51 + SLASH = 52 + BACKSLASH = 53 + GRAVE = 54 + EQUAL = 55 + HYPHEN = 56 + # Whitespace/editing + SPACE = 57 + ENTER = 58 + BACKSPACE = 59 + TAB = 60 + # Navigation + PAGE_UP = 61 + PAGE_DOWN = 62 + END = 63 + HOME = 64 + INSERT = 65 + DELETE = 66 + # Numpad operators + ADD = 67 + SUBTRACT = 68 + MULTIPLY = 69 + DIVIDE = 70 + # Arrows + LEFT = 71 + RIGHT = 72 + UP = 73 + DOWN = 74 + # Numpad numbers + NUMPAD_0 = 75 + NUMPAD_1 = 76 + NUMPAD_2 = 77 + NUMPAD_3 = 78 + NUMPAD_4 = 79 + NUMPAD_5 = 80 + NUMPAD_6 = 81 + NUMPAD_7 = 82 + NUMPAD_8 = 83 + NUMPAD_9 = 84 + # Function keys + F1 = 85 + F2 = 86 + F3 = 87 + F4 = 88 + F5 = 89 + F6 = 90 + F7 = 91 + F8 = 92 + F9 = 93 + F10 = 94 + F11 = 95 + F12 = 96 + F13 = 97 + F14 = 98 + F15 = 99 + # Misc + PAUSE = 100 + UNKNOWN = -1 + +class MouseButton(IntEnum): + """Mouse button codes. + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + MouseButton.LEFT == 'left' # True + MouseButton.RIGHT == 'right' # True + """ + LEFT = 0 + RIGHT = 1 + MIDDLE = 2 + X1 = 3 + X2 = 4 + +class InputState(IntEnum): + """Input event states (pressed/released). + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + InputState.PRESSED == 'start' # True + InputState.RELEASED == 'end' # True + """ + PRESSED = 0 + RELEASED = 1 + +class Easing(IntEnum): + """Easing functions for animations.""" + LINEAR = 0 + EASE_IN = 1 + EASE_OUT = 2 + EASE_IN_OUT = 3 + EASE_IN_QUAD = 4 + EASE_OUT_QUAD = 5 + EASE_IN_OUT_QUAD = 6 + EASE_IN_CUBIC = 7 + EASE_OUT_CUBIC = 8 + EASE_IN_OUT_CUBIC = 9 + EASE_IN_QUART = 10 + EASE_OUT_QUART = 11 + EASE_IN_OUT_QUART = 12 + EASE_IN_SINE = 13 + EASE_OUT_SINE = 14 + EASE_IN_OUT_SINE = 15 + EASE_IN_EXPO = 16 + EASE_OUT_EXPO = 17 + EASE_IN_OUT_EXPO = 18 + EASE_IN_CIRC = 19 + EASE_OUT_CIRC = 20 + EASE_IN_OUT_CIRC = 21 + EASE_IN_ELASTIC = 22 + EASE_OUT_ELASTIC = 23 + EASE_IN_OUT_ELASTIC = 24 + EASE_IN_BACK = 25 + EASE_OUT_BACK = 26 + EASE_IN_OUT_BACK = 27 + EASE_IN_BOUNCE = 28 + EASE_OUT_BOUNCE = 29 + EASE_IN_OUT_BOUNCE = 30 + +class FOV(IntEnum): + """Field of view algorithms for visibility calculations.""" + BASIC = 0 + DIAMOND = 1 + SHADOW = 2 + PERMISSIVE_0 = 3 + PERMISSIVE_1 = 4 + PERMISSIVE_2 = 5 + PERMISSIVE_3 = 6 + PERMISSIVE_4 = 7 + PERMISSIVE_5 = 8 + PERMISSIVE_6 = 9 + PERMISSIVE_7 = 10 + PERMISSIVE_8 = 11 + RESTRICTIVE = 12 + SYMMETRIC_SHADOWCAST = 13 + # Classes class Color: diff --git a/tests/unit/test_input_enums.py b/tests/unit/test_input_enums.py new file mode 100644 index 0000000..d1e8334 --- /dev/null +++ b/tests/unit/test_input_enums.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Test Key, MouseButton, and InputState enum functionality. + +Tests the new input-related enums that provide type-safe alternatives to +string-based key codes, mouse buttons, and event states. +""" + +import mcrfpy +import sys + + +def test_key_enum(): + """Test Key enum members and backwards compatibility.""" + print("Testing Key enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'Key'), "mcrfpy.Key should exist" + assert hasattr(mcrfpy.Key, 'A'), "Key.A should exist" + assert hasattr(mcrfpy.Key, 'ESCAPE'), "Key.ESCAPE should exist" + assert hasattr(mcrfpy.Key, 'LEFT_SHIFT'), "Key.LEFT_SHIFT should exist" + + # Test int values + assert int(mcrfpy.Key.A) == 0, "Key.A should be 0" + assert int(mcrfpy.Key.ESCAPE) == 36, "Key.ESCAPE should be 36" + + # Test backwards compatibility with legacy strings + assert mcrfpy.Key.A == "A", "Key.A should equal 'A'" + assert mcrfpy.Key.ESCAPE == "Escape", "Key.ESCAPE should equal 'Escape'" + assert mcrfpy.Key.LEFT_SHIFT == "LShift", "Key.LEFT_SHIFT should equal 'LShift'" + assert mcrfpy.Key.RIGHT_CONTROL == "RControl", "Key.RIGHT_CONTROL should equal 'RControl'" + assert mcrfpy.Key.NUM_1 == "Num1", "Key.NUM_1 should equal 'Num1'" + assert mcrfpy.Key.NUMPAD_5 == "Numpad5", "Key.NUMPAD_5 should equal 'Numpad5'" + assert mcrfpy.Key.F12 == "F12", "Key.F12 should equal 'F12'" + assert mcrfpy.Key.SPACE == "Space", "Key.SPACE should equal 'Space'" + + # Test that enum name also matches + assert mcrfpy.Key.ESCAPE == "ESCAPE", "Key.ESCAPE should also equal 'ESCAPE'" + + print(" All Key tests passed") + + +def test_mouse_button_enum(): + """Test MouseButton enum members and backwards compatibility.""" + print("Testing MouseButton enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'MouseButton'), "mcrfpy.MouseButton should exist" + assert hasattr(mcrfpy.MouseButton, 'LEFT'), "MouseButton.LEFT should exist" + assert hasattr(mcrfpy.MouseButton, 'RIGHT'), "MouseButton.RIGHT should exist" + assert hasattr(mcrfpy.MouseButton, 'MIDDLE'), "MouseButton.MIDDLE should exist" + + # Test int values + assert int(mcrfpy.MouseButton.LEFT) == 0, "MouseButton.LEFT should be 0" + assert int(mcrfpy.MouseButton.RIGHT) == 1, "MouseButton.RIGHT should be 1" + assert int(mcrfpy.MouseButton.MIDDLE) == 2, "MouseButton.MIDDLE should be 2" + + # Test backwards compatibility with legacy strings + assert mcrfpy.MouseButton.LEFT == "left", "MouseButton.LEFT should equal 'left'" + assert mcrfpy.MouseButton.RIGHT == "right", "MouseButton.RIGHT should equal 'right'" + assert mcrfpy.MouseButton.MIDDLE == "middle", "MouseButton.MIDDLE should equal 'middle'" + assert mcrfpy.MouseButton.X1 == "x1", "MouseButton.X1 should equal 'x1'" + assert mcrfpy.MouseButton.X2 == "x2", "MouseButton.X2 should equal 'x2'" + + # Test that enum name also matches + assert mcrfpy.MouseButton.LEFT == "LEFT", "MouseButton.LEFT should also equal 'LEFT'" + + print(" All MouseButton tests passed") + + +def test_input_state_enum(): + """Test InputState enum members and backwards compatibility.""" + print("Testing InputState enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'InputState'), "mcrfpy.InputState should exist" + assert hasattr(mcrfpy.InputState, 'PRESSED'), "InputState.PRESSED should exist" + assert hasattr(mcrfpy.InputState, 'RELEASED'), "InputState.RELEASED should exist" + + # Test int values + assert int(mcrfpy.InputState.PRESSED) == 0, "InputState.PRESSED should be 0" + assert int(mcrfpy.InputState.RELEASED) == 1, "InputState.RELEASED should be 1" + + # Test backwards compatibility with legacy strings + assert mcrfpy.InputState.PRESSED == "start", "InputState.PRESSED should equal 'start'" + assert mcrfpy.InputState.RELEASED == "end", "InputState.RELEASED should equal 'end'" + + # Test that enum name also matches + assert mcrfpy.InputState.PRESSED == "PRESSED", "InputState.PRESSED should also equal 'PRESSED'" + assert mcrfpy.InputState.RELEASED == "RELEASED", "InputState.RELEASED should also equal 'RELEASED'" + + print(" All InputState tests passed") + + +def test_enum_repr(): + """Test that enum repr/str work correctly.""" + print("Testing enum repr/str...") + + # Test repr + assert "Key.ESCAPE" in repr(mcrfpy.Key.ESCAPE), f"repr should contain 'Key.ESCAPE', got {repr(mcrfpy.Key.ESCAPE)}" + assert "MouseButton.LEFT" in repr(mcrfpy.MouseButton.LEFT), f"repr should contain 'MouseButton.LEFT'" + assert "InputState.PRESSED" in repr(mcrfpy.InputState.PRESSED), f"repr should contain 'InputState.PRESSED'" + + # Test str + assert str(mcrfpy.Key.ESCAPE) == "ESCAPE", f"str(Key.ESCAPE) should be 'ESCAPE', got {str(mcrfpy.Key.ESCAPE)}" + assert str(mcrfpy.MouseButton.LEFT) == "LEFT", f"str(MouseButton.LEFT) should be 'LEFT'" + assert str(mcrfpy.InputState.PRESSED) == "PRESSED", f"str(InputState.PRESSED) should be 'PRESSED'" + + print(" All repr/str tests passed") + + +def main(): + """Run all enum tests.""" + print("=" * 50) + print("Input Enum Unit Tests") + print("=" * 50) + + try: + test_key_enum() + test_mouse_button_enum() + test_input_state_enum() + test_enum_repr() + + print() + print("=" * 50) + print("All tests PASSED") + print("=" * 50) + sys.exit(0) + + except AssertionError as e: + print(f"\nTest FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\nUnexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()