feat: Add Vector convenience features - indexing, tuple comparison, floor

Implements issue #109 improvements to mcrfpy.Vector:

- Sequence protocol: v[0], v[1], v[-1], v[-2], len(v), tuple(v), x,y = v
- Tuple comparison: v == (5, 6), v != (1, 2) works bidirectionally
- .floor() method: returns new Vector with floored coordinates
- .int property: returns (int(floor(x)), int(floor(y))) tuple for dict keys

The sequence protocol enables unpacking and iteration, making Vector
interoperable with code expecting tuples. The tuple comparison fixes
compatibility issues where functions returning Vector broke code expecting
tuple comparison (e.g., in Crypt of Sokoban).

Closes #109

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-26 09:37:14 -05:00
commit f041a0c8ca
3 changed files with 329 additions and 14 deletions

View file

@ -8,6 +8,8 @@ PyGetSetDef PyVector::getsetters[] = {
MCRF_PROPERTY(x, "X coordinate of the vector (float)"), (void*)0},
{"y", (getter)PyVector::get_member, (setter)PyVector::set_member,
MCRF_PROPERTY(y, "Y coordinate of the vector (float)"), (void*)1},
{"int", (getter)PyVector::get_int, NULL,
MCRF_PROPERTY(int, "Integer tuple (floor of x and y) for use as dict keys. Read-only."), NULL},
{NULL}
};
@ -60,6 +62,13 @@ PyMethodDef PyVector::methods[] = {
MCRF_DESC("Create a copy of this vector."),
MCRF_RETURNS("Vector: New Vector object with same x and y values")
)},
{"floor", (PyCFunction)PyVector::floor, METH_NOARGS,
MCRF_METHOD(Vector, floor,
MCRF_SIG("()", "Vector"),
MCRF_DESC("Return a new vector with floored (integer) coordinates."),
MCRF_RETURNS("Vector: New Vector with floor(x) and floor(y)")
MCRF_NOTE("Useful for grid-based positioning. For a hashable tuple, use the .int property instead.")
)},
{NULL}
};
@ -102,6 +111,19 @@ namespace mcrfpydef {
.nb_matrix_multiply = 0,
.nb_inplace_matrix_multiply = 0
};
PySequenceMethods PyVector_as_sequence = {
.sq_length = PyVector::sequence_length,
.sq_concat = 0,
.sq_repeat = 0,
.sq_item = PyVector::sequence_item,
.was_sq_slice = 0,
.sq_ass_item = 0,
.was_sq_ass_slice = 0,
.sq_contains = 0,
.sq_inplace_concat = 0,
.sq_inplace_repeat = 0
};
}
PyVector::PyVector(sf::Vector2f target)
@ -397,29 +419,65 @@ int PyVector::bool_check(PyObject* self)
PyObject* PyVector::richcompare(PyObject* left, PyObject* right, int op)
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
if (!PyObject_IsInstance(left, (PyObject*)type) || !PyObject_IsInstance(right, (PyObject*)type)) {
float left_x, left_y, right_x, right_y;
// Extract left operand values
if (PyObject_IsInstance(left, (PyObject*)type)) {
PyVectorObject* vec = (PyVectorObject*)left;
left_x = vec->data.x;
left_y = vec->data.y;
} else if (PyTuple_Check(left) && PyTuple_Size(left) == 2) {
PyObject* x_obj = PyTuple_GetItem(left, 0);
PyObject* y_obj = PyTuple_GetItem(left, 1);
if ((PyFloat_Check(x_obj) || PyLong_Check(x_obj)) &&
(PyFloat_Check(y_obj) || PyLong_Check(y_obj))) {
left_x = (float)PyFloat_AsDouble(x_obj);
left_y = (float)PyFloat_AsDouble(y_obj);
} else {
Py_INCREF(Py_NotImplemented);
return Py_NotImplemented;
}
} else {
Py_INCREF(Py_NotImplemented);
return Py_NotImplemented;
}
PyVectorObject* vec1 = (PyVectorObject*)left;
PyVectorObject* vec2 = (PyVectorObject*)right;
// Extract right operand values
if (PyObject_IsInstance(right, (PyObject*)type)) {
PyVectorObject* vec = (PyVectorObject*)right;
right_x = vec->data.x;
right_y = vec->data.y;
} else if (PyTuple_Check(right) && PyTuple_Size(right) == 2) {
PyObject* x_obj = PyTuple_GetItem(right, 0);
PyObject* y_obj = PyTuple_GetItem(right, 1);
if ((PyFloat_Check(x_obj) || PyLong_Check(x_obj)) &&
(PyFloat_Check(y_obj) || PyLong_Check(y_obj))) {
right_x = (float)PyFloat_AsDouble(x_obj);
right_y = (float)PyFloat_AsDouble(y_obj);
} else {
Py_INCREF(Py_NotImplemented);
return Py_NotImplemented;
}
} else {
Py_INCREF(Py_NotImplemented);
return Py_NotImplemented;
}
bool result = false;
switch (op) {
case Py_EQ:
result = (vec1->data.x == vec2->data.x && vec1->data.y == vec2->data.y);
result = (left_x == right_x && left_y == right_y);
break;
case Py_NE:
result = (vec1->data.x != vec2->data.x || vec1->data.y != vec2->data.y);
result = (left_x != right_x || left_y != right_y);
break;
default:
Py_INCREF(Py_NotImplemented);
return Py_NotImplemented;
}
if (result)
Py_RETURN_TRUE;
else
@ -500,10 +558,54 @@ PyObject* PyVector::copy(PyVectorObject* self, PyObject* Py_UNUSED(ignored))
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
auto result = (PyVectorObject*)type->tp_alloc(type, 0);
if (result) {
result->data = self->data;
}
return (PyObject*)result;
}
PyObject* PyVector::floor(PyVectorObject* self, PyObject* Py_UNUSED(ignored))
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
auto result = (PyVectorObject*)type->tp_alloc(type, 0);
if (result) {
result->data = sf::Vector2f(std::floor(self->data.x), std::floor(self->data.y));
}
return (PyObject*)result;
}
// Sequence protocol implementation
Py_ssize_t PyVector::sequence_length(PyObject* self)
{
return 2; // Vectors always have exactly 2 elements
}
PyObject* PyVector::sequence_item(PyObject* obj, Py_ssize_t index)
{
PyVectorObject* self = (PyVectorObject*)obj;
// Note: Python already handles negative index normalization when sq_length is defined
// So v[-1] arrives here as index=1, v[-2] as index=0
// Out-of-range negative indices (like v[-3]) arrive as negative values (e.g., -1)
if (index == 0) {
return PyFloat_FromDouble(self->data.x);
} else if (index == 1) {
return PyFloat_FromDouble(self->data.y);
} else {
PyErr_SetString(PyExc_IndexError, "Vector index out of range (must be 0 or 1)");
return NULL;
}
}
// Property: .int - returns integer tuple for use as dict keys
PyObject* PyVector::get_int(PyObject* obj, void* closure)
{
PyVectorObject* self = (PyVectorObject*)obj;
long ix = (long)std::floor(self->data.x);
long iy = (long)std::floor(self->data.y);
return Py_BuildValue("(ll)", ix, iy);
}

View file

@ -45,15 +45,24 @@ public:
static PyObject* distance_to(PyVectorObject*, PyObject*);
static PyObject* angle(PyVectorObject*, PyObject*);
static PyObject* copy(PyVectorObject*, PyObject*);
static PyObject* floor(PyVectorObject*, PyObject*);
// Sequence protocol
static Py_ssize_t sequence_length(PyObject*);
static PyObject* sequence_item(PyObject*, Py_ssize_t);
// Additional properties
static PyObject* get_int(PyObject*, void*);
static PyGetSetDef getsetters[];
static PyMethodDef methods[];
};
namespace mcrfpydef {
// Forward declare the PyNumberMethods structure
// Forward declare the PyNumberMethods and PySequenceMethods structures
extern PyNumberMethods PyVector_as_number;
extern PySequenceMethods PyVector_as_sequence;
static PyTypeObject PyVectorType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Vector",
@ -61,6 +70,7 @@ namespace mcrfpydef {
.tp_itemsize = 0,
.tp_repr = PyVector::repr,
.tp_as_number = &PyVector_as_number,
.tp_as_sequence = &PyVector_as_sequence,
.tp_hash = PyVector::hash,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("SFML Vector Object"),