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
parent afcb54d9fe
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)
@ -398,22 +420,58 @@ 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);
@ -507,3 +565,47 @@ PyObject* PyVector::copy(PyVectorObject* self, PyObject* Py_UNUSED(ignored))
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,14 +45,23 @@ 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},
@ -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"),

View File

@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Unit tests for Vector convenience features (Issue #109)
Tests:
- Sequence protocol: indexing, negative indexing, iteration, unpacking
- Tuple comparison: Vector == tuple, Vector != tuple
- Integer conversion: .floor() method, .int property
- Boolean check: falsey for (0, 0)
"""
import mcrfpy
import sys
def approx(a, b, epsilon=1e-5):
"""Check if two floats are approximately equal (handles float32 precision)"""
return abs(a - b) < epsilon
def test_indexing():
"""Test sequence protocol indexing"""
# Use values that are exact in float32: 3.5 = 7/2, 7.5 = 15/2
v = mcrfpy.Vector(3.5, 7.5)
# Positive indices
assert v[0] == 3.5, f"v[0] should be 3.5, got {v[0]}"
assert v[1] == 7.5, f"v[1] should be 7.5, got {v[1]}"
# Negative indices
assert v[-1] == 7.5, f"v[-1] should be 7.5, got {v[-1]}"
assert v[-2] == 3.5, f"v[-2] should be 3.5, got {v[-2]}"
# Out of bounds
try:
_ = v[2]
assert False, "v[2] should raise IndexError"
except IndexError:
pass
try:
_ = v[-3]
assert False, "v[-3] should raise IndexError"
except IndexError:
pass
print(" [PASS] Indexing")
def test_length():
"""Test len() on Vector"""
v = mcrfpy.Vector(1, 2)
assert len(v) == 2, f"len(Vector) should be 2, got {len(v)}"
print(" [PASS] Length")
def test_iteration():
"""Test iteration and unpacking"""
# Use values that are exact in float32
v = mcrfpy.Vector(10.5, 20.5)
# Iteration - use approximate comparison for float32 precision
values = list(v)
assert len(values) == 2, f"list(v) should have 2 elements"
assert approx(values[0], 10.5), f"list(v)[0] should be ~10.5, got {values[0]}"
assert approx(values[1], 20.5), f"list(v)[1] should be ~20.5, got {values[1]}"
# Unpacking
x, y = v
assert approx(x, 10.5), f"Unpacked x should be ~10.5, got {x}"
assert approx(y, 20.5), f"Unpacked y should be ~20.5, got {y}"
# tuple() conversion
t = tuple(v)
assert len(t) == 2 and approx(t[0], 10.5) and approx(t[1], 20.5), f"tuple(v) should be ~(10.5, 20.5), got {t}"
print(" [PASS] Iteration and unpacking")
def test_tuple_comparison():
"""Test comparison with tuples"""
# Use integer values which are exact in float32
v = mcrfpy.Vector(5, 6)
# Vector == tuple (integers are exact)
assert v == (5, 6), "Vector(5, 6) should equal (5, 6)"
assert v == (5.0, 6.0), "Vector(5, 6) should equal (5.0, 6.0)"
# Vector != tuple
assert v != (5, 7), "Vector(5, 6) should not equal (5, 7)"
assert v != (4, 6), "Vector(5, 6) should not equal (4, 6)"
# Tuple == Vector (reverse comparison)
assert (5, 6) == v, "(5, 6) should equal Vector(5, 6)"
assert (5, 7) != v, "(5, 7) should not equal Vector(5, 6)"
# Edge cases
v_zero = mcrfpy.Vector(0, 0)
assert v_zero == (0, 0), "Vector(0, 0) should equal (0, 0)"
assert v_zero == (0.0, 0.0), "Vector(0, 0) should equal (0.0, 0.0)"
# Negative values - use exact float32 values (x.5 are exact)
v_neg = mcrfpy.Vector(-3.5, -7.5)
assert v_neg == (-3.5, -7.5), "Vector(-3.5, -7.5) should equal (-3.5, -7.5)"
print(" [PASS] Tuple comparison")
def test_floor_method():
"""Test .floor() method"""
# Use values that clearly floor to different integers
v = mcrfpy.Vector(3.75, -2.25) # exact in float32
floored = v.floor()
assert isinstance(floored, mcrfpy.Vector), ".floor() should return a Vector"
assert floored.x == 3.0, f"floor(3.75) should be 3.0, got {floored.x}"
assert floored.y == -3.0, f"floor(-2.25) should be -3.0, got {floored.y}"
# Positive values (use exact float32 values)
v2 = mcrfpy.Vector(5.875, 0.125) # exact in float32
f2 = v2.floor()
assert f2 == (5.0, 0.0), f"floor(5.875, 0.125) should be (5.0, 0.0), got ({f2.x}, {f2.y})"
# Already integers
v3 = mcrfpy.Vector(10.0, 20.0)
f3 = v3.floor()
assert f3 == (10.0, 20.0), f"floor(10.0, 20.0) should be (10.0, 20.0)"
print(" [PASS] .floor() method")
def test_int_property():
"""Test .int property"""
# Use exact float32 values
v = mcrfpy.Vector(3.75, -2.25)
int_tuple = v.int
assert isinstance(int_tuple, tuple), ".int should return a tuple"
assert len(int_tuple) == 2, ".int tuple should have 2 elements"
assert int_tuple == (3, -3), f".int should be (3, -3), got {int_tuple}"
# Check it's hashable (can be used as dict key)
d = {}
d[v.int] = "test"
assert d[(3, -3)] == "test", ".int tuple should work as dict key"
# Positive values (use exact float32 values)
v2 = mcrfpy.Vector(5.875, 0.125)
assert v2.int == (5, 0), f".int should be (5, 0), got {v2.int}"
print(" [PASS] .int property")
def test_bool_check():
"""Test boolean conversion (already implemented, verify it works)"""
v_zero = mcrfpy.Vector(0, 0)
v_nonzero = mcrfpy.Vector(1, 0)
v_nonzero2 = mcrfpy.Vector(0, 1)
assert not bool(v_zero), "Vector(0, 0) should be falsey"
assert bool(v_nonzero), "Vector(1, 0) should be truthy"
assert bool(v_nonzero2), "Vector(0, 1) should be truthy"
# In if statement
if v_zero:
assert False, "Vector(0, 0) should not pass if check"
if not v_nonzero:
assert False, "Vector(1, 0) should pass if check"
print(" [PASS] Boolean check")
def test_combined_operations():
"""Test that new features work together with existing operations"""
# Use exact float32 values
v1 = mcrfpy.Vector(3.5, 4.5)
v2 = mcrfpy.Vector(1.5, 2.5)
# Arithmetic then tuple comparison (sums are exact)
result = v1 + v2
assert result == (5.0, 7.0), f"(3.5+1.5, 4.5+2.5) should equal (5.0, 7.0), got ({result.x}, {result.y})"
# Floor then use as dict key
floored = v1.floor()
positions = {floored.int: "player"}
assert (3, 4) in positions, "floored.int should work as dict key"
# Unpack, modify, compare (products are exact)
x, y = v1
v3 = mcrfpy.Vector(x * 2, y * 2)
assert v3 == (7.0, 9.0), f"Unpacking and creating new vector should work, got ({v3.x}, {v3.y})"
print(" [PASS] Combined operations")
def run_tests():
"""Run all tests"""
print("Testing Vector convenience features (Issue #109)...")
test_indexing()
test_length()
test_iteration()
test_tuple_comparison()
test_floor_method()
test_int_property()
test_bool_check()
test_combined_operations()
print("\n[ALL TESTS PASSED]")
sys.exit(0)
# Run tests immediately (no game loop needed)
run_tests()