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:
parent
afcb54d9fe
commit
f041a0c8ca
112
src/PyVector.cpp
112
src/PyVector.cpp
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue