From 4b13e5f5dbdaca5222499364c7d6a4f8f8948100 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 16 Mar 2026 08:41:44 -0400 Subject: [PATCH] Phase 4.2: Add GridView UIDrawable type (addresses #252) GridView is a new UIDrawable that renders a GridData object independently. Multiple GridViews can reference the same Grid for split-screen, minimap, or different camera/zoom perspectives on shared grid state. - New files: UIGridView.h/cpp with full rendering pipeline (copied from UIGrid::render, adapted to use grid_data pointer) - Add UIGRIDVIEW to PyObjectsEnum - Add UIGRIDVIEW cases to all switch(derived_type()) sites: Animation.cpp, UICollection.cpp, UIDrawable.cpp, McRFPy_API.cpp, ImGuiSceneExplorer.cpp - Python type: mcrfpy.GridView(grid=, pos=, size=, zoom=, fill_color=) - Properties: grid, center, zoom, fill_color, texture - Register type with metaclass for callback support All 258 existing tests pass. New GridView test suite added. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Animation.cpp | 12 + src/ImGuiSceneExplorer.cpp | 2 + src/McRFPy_API.cpp | 14 + src/PyScene.cpp | 1 + src/UICollection.cpp | 15 ++ src/UIDrawable.cpp | 14 +- src/UIDrawable.h | 3 +- src/UIGridView.cpp | 516 ++++++++++++++++++++++++++++++++++++ src/UIGridView.h | 158 +++++++++++ tests/unit/gridview_test.py | 91 +++++++ 10 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 src/UIGridView.cpp create mode 100644 src/UIGridView.h create mode 100644 tests/unit/gridview_test.py diff --git a/src/Animation.cpp b/src/Animation.cpp index 3631d91..4fd20c2 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -11,6 +11,7 @@ #include "UICaption.h" #include "UISprite.h" #include "UIGrid.h" +#include "UIGridView.h" #include "UILine.h" #include "UICircle.h" #include "UIArc.h" @@ -557,6 +558,17 @@ static PyObject* convertDrawableToPython(std::shared_ptr drawable) { obj = (PyObject*)pyObj; break; } + case PyObjectsEnum::UIGRIDVIEW: + { + type = &mcrfpydef::PyUIGridViewType; + auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } case PyObjectsEnum::UILINE: { type = &mcrfpydef::PyUILineType; diff --git a/src/ImGuiSceneExplorer.cpp b/src/ImGuiSceneExplorer.cpp index b11721f..be447c2 100644 --- a/src/ImGuiSceneExplorer.cpp +++ b/src/ImGuiSceneExplorer.cpp @@ -12,6 +12,7 @@ #include "UICaption.h" #include "UISprite.h" #include "UIGrid.h" +#include "UIGridView.h" #include "UIEntity.h" #include "ImGuiConsole.h" #include "PythonObjectCache.h" @@ -285,6 +286,7 @@ const char* ImGuiSceneExplorer::getTypeName(UIDrawable* drawable) { case PyObjectsEnum::UICAPTION: return "Caption"; case PyObjectsEnum::UISPRITE: return "Sprite"; case PyObjectsEnum::UIGRID: return "Grid"; + case PyObjectsEnum::UIGRIDVIEW: return "GridView"; case PyObjectsEnum::UILINE: return "Line"; case PyObjectsEnum::UICIRCLE: return "Circle"; case PyObjectsEnum::UIARC: return "Arc"; diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index f31b62e..2a28d50 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -18,6 +18,7 @@ #include "PyInputState.h" #include "PyBehavior.h" #include "PyTrigger.h" +#include "UIGridView.h" #include "PySound.h" #include "PySoundBuffer.h" #include "PyMusic.h" @@ -465,6 +466,7 @@ PyObject* PyInit_mcrfpy() PyTypeObject* ui_types_with_callbacks[] = { &PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyUIGridType, &PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType, + &mcrfpydef::PyUIGridViewType, nullptr }; for (int i = 0; ui_types_with_callbacks[i] != nullptr; i++) { @@ -482,6 +484,7 @@ PyObject* PyInit_mcrfpy() /*UI widgets*/ &PyUICaptionType, &PyUISpriteType, &PyUIFrameType, &PyUIEntityType, &PyUIGridType, &PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType, + &mcrfpydef::PyUIGridViewType, /*3D entities*/ &mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType, @@ -635,6 +638,7 @@ PyObject* PyInit_mcrfpy() PyUICaptionType.tp_weaklistoffset = offsetof(PyUICaptionObject, weakreflist); PyUISpriteType.tp_weaklistoffset = offsetof(PyUISpriteObject, weakreflist); PyUIGridType.tp_weaklistoffset = offsetof(PyUIGridObject, weakreflist); + mcrfpydef::PyUIGridViewType.tp_weaklistoffset = offsetof(PyUIGridViewObject, weakreflist); PyUIEntityType.tp_weaklistoffset = offsetof(PyUIEntityObject, weakreflist); PyUILineType.tp_weaklistoffset = offsetof(PyUILineObject, weakreflist); PyUICircleType.tp_weaklistoffset = offsetof(PyUICircleObject, weakreflist); @@ -1596,6 +1600,16 @@ static void find_in_collection(std::vector>* collect } break; } + case PyObjectsEnum::UIGRIDVIEW: { + auto gridview = std::static_pointer_cast(drawable); + auto type = &mcrfpydef::PyUIGridViewType; + auto o = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (o) { + o->data = gridview; + py_obj = (PyObject*)o; + } + break; + } default: break; } diff --git a/src/PyScene.cpp b/src/PyScene.cpp index eb053a0..8771bb2 100644 --- a/src/PyScene.cpp +++ b/src/PyScene.cpp @@ -4,6 +4,7 @@ #include "PyCallable.h" #include "UIFrame.h" #include "UIGrid.h" +#include "UIGridView.h" #include "McRFPy_Automation.h" // #111 - For simulated mouse position #include "PythonObjectCache.h" // #184 - For subclass callback support #include "McRFPy_API.h" // For Vector type access diff --git a/src/UICollection.cpp b/src/UICollection.cpp index 821ebf8..71eecd4 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -4,6 +4,7 @@ #include "UICaption.h" #include "UISprite.h" #include "UIGrid.h" +#include "UIGridView.h" #include "UILine.h" #include "UICircle.h" #include "UIArc.h" @@ -78,6 +79,17 @@ static PyObject* convertDrawableToPython(std::shared_ptr drawable) { obj = (PyObject*)pyObj; break; } + case PyObjectsEnum::UIGRIDVIEW: + { + type = &PyUIGridViewType; + auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } case PyObjectsEnum::UILINE: { type = &PyUILineType; @@ -152,6 +164,8 @@ static std::shared_ptr extractDrawable(PyObject* o) { return ((PyUISpriteObject*)o)->data; if (PyObject_IsInstance(o, (PyObject*)&PyUIGridType)) return ((PyUIGridObject*)o)->data; + if (PyObject_IsInstance(o, (PyObject*)&PyUIGridViewType)) + return ((PyUIGridViewObject*)o)->data; if (PyObject_IsInstance(o, (PyObject*)&PyUILineType)) return ((PyUILineObject*)o)->data; if (PyObject_IsInstance(o, (PyObject*)&PyUICircleType)) @@ -1034,6 +1048,7 @@ PyObject* UICollection::repr(PyUICollectionObject* self) case PyObjectsEnum::UICAPTION: caption_count++; break; case PyObjectsEnum::UISPRITE: sprite_count++; break; case PyObjectsEnum::UIGRID: grid_count++; break; + case PyObjectsEnum::UIGRIDVIEW: other_count++; break; default: other_count++; break; } } diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 05a54fd..67e71cc 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -4,6 +4,7 @@ #include "UICaption.h" #include "UISprite.h" #include "UIGrid.h" +#include "UIGridView.h" #include "UILine.h" #include "UICircle.h" #include "UIArc.h" @@ -1032,7 +1033,7 @@ void UIDrawable::removeFromParent() { } frame->children_need_sort = true; } - else if (p->derived_type() == PyObjectsEnum::UIGRID) { + else if (p->derived_type() == PyObjectsEnum::UIGRID || p->derived_type() == PyObjectsEnum::UIGRIDVIEW) { auto grid = std::static_pointer_cast(p); auto& children = *grid->children; @@ -1369,6 +1370,17 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { obj = (PyObject*)pyObj; break; } + case PyObjectsEnum::UIGRIDVIEW: + { + type = &mcrfpydef::PyUIGridViewType; + auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(parent_ptr); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } default: Py_RETURN_NONE; } diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 3d2b7ad..64a8ef4 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -33,7 +33,8 @@ enum PyObjectsEnum : int UILINE, UICIRCLE, UIARC, - UIVIEWPORT3D + UIVIEWPORT3D, + UIGRIDVIEW // #252: rendering view for GridData }; class UIDrawable diff --git a/src/UIGridView.cpp b/src/UIGridView.cpp new file mode 100644 index 0000000..ac21323 --- /dev/null +++ b/src/UIGridView.cpp @@ -0,0 +1,516 @@ +// UIGridView.cpp - Rendering view for GridData (#252) +#include "UIGridView.h" +#include "UIGrid.h" +#include "UIEntity.h" +#include "GameEngine.h" +#include "McRFPy_API.h" +#include "Resources.h" +#include "Profiler.h" +#include "PyShader.h" +#include "PyUniformCollection.h" +#include "PyPositionHelper.h" +#include "PyVector.h" +#include "PythonObjectCache.h" +#include +#include + +UIGridView::UIGridView() +{ + renderTexture.create(1, 1); + renderTextureSize = {1, 1}; + output.setTextureRect(sf::IntRect(0, 0, 0, 0)); + output.setPosition(0, 0); + output.setTexture(renderTexture.getTexture()); + box.setSize(sf::Vector2f(256, 256)); + box.setFillColor(sf::Color(0, 0, 0, 0)); +} + +UIGridView::~UIGridView() {} + +PyObjectsEnum UIGridView::derived_type() +{ + return PyObjectsEnum::UIGRIDVIEW; +} + +void UIGridView::ensureRenderTextureSize() +{ + sf::Vector2u resolution{1920, 1080}; + if (Resources::game) { + resolution = Resources::game->getGameResolution(); + } + unsigned int required_w = std::min(resolution.x, 4096u); + unsigned int required_h = std::min(resolution.y, 4096u); + + if (renderTextureSize.x != required_w || renderTextureSize.y != required_h) { + renderTexture.create(required_w, required_h); + renderTextureSize = {required_w, required_h}; + output.setTexture(renderTexture.getTexture()); + } +} + +sf::FloatRect UIGridView::get_bounds() const +{ + return sf::FloatRect(box.getPosition(), box.getSize()); +} + +void UIGridView::move(float dx, float dy) +{ + box.move(dx, dy); + position += sf::Vector2f(dx, dy); +} + +void UIGridView::resize(float w, float h) +{ + box.setSize(sf::Vector2f(w, h)); +} + +sf::Vector2f UIGridView::getEffectiveCellSize() const +{ + int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + return sf::Vector2f(cw * zoom, ch * zoom); +} + +std::optional UIGridView::screenToCell(sf::Vector2f screen_pos) const +{ + if (!grid_data) return std::nullopt; + if (!box.getGlobalBounds().contains(screen_pos)) return std::nullopt; + + int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + + sf::Vector2f local = screen_pos - box.getPosition(); + int left_sp = center_x - (box.getSize().x / 2.0 / zoom); + int top_sp = center_y - (box.getSize().y / 2.0 / zoom); + + float gx = (local.x / zoom + left_sp) / cw; + float gy = (local.y / zoom + top_sp) / ch; + + int cx = static_cast(std::floor(gx)); + int cy = static_cast(std::floor(gy)); + + if (cx < 0 || cx >= grid_data->grid_w || cy < 0 || cy >= grid_data->grid_h) + return std::nullopt; + return sf::Vector2i(cx, cy); +} + +void UIGridView::center_camera() +{ + if (!grid_data) return; + int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + center_x = (grid_data->grid_w / 2.0f) * cw; + center_y = (grid_data->grid_h / 2.0f) * ch; +} + +void UIGridView::center_camera(float tile_x, float tile_y) +{ + int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + center_x = tile_x * cw; + center_y = tile_y * ch; +} + +// ========================================================================= +// Render — adapted from UIGrid::render() +// ========================================================================= +void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target) +{ + if (!visible || !grid_data) return; + + ensureRenderTextureSize(); + + int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + + bool has_camera_rotation = (camera_rotation != 0.0f); + float grid_w_px = box.getSize().x; + float grid_h_px = box.getSize().y; + + float rad = camera_rotation * (M_PI / 180.0f); + float cos_r = std::cos(rad); + float sin_r = std::sin(rad); + float abs_cos = std::abs(cos_r); + float abs_sin = std::abs(sin_r); + float aabb_w = grid_w_px * abs_cos + grid_h_px * abs_sin; + float aabb_h = grid_w_px * abs_sin + grid_h_px * abs_cos; + + sf::RenderTexture* activeTexture = &renderTexture; + + if (has_camera_rotation) { + unsigned int needed_size = static_cast(std::max(aabb_w, aabb_h) + 1); + if (rotationTextureSize < needed_size) { + rotationTexture.create(needed_size, needed_size); + rotationTextureSize = needed_size; + } + activeTexture = &rotationTexture; + activeTexture->clear(fill_color); + } else { + output.setPosition(box.getPosition() + offset); + output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px)); + renderTexture.clear(fill_color); + } + + float render_w = has_camera_rotation ? aabb_w : grid_w_px; + float render_h = has_camera_rotation ? aabb_h : grid_h_px; + + float center_x_sq = center_x / cell_width; + float center_y_sq = center_y / cell_height; + float width_sq = render_w / (cell_width * zoom); + float height_sq = render_h / (cell_height * zoom); + float left_edge = center_x_sq - (width_sq / 2.0); + float top_edge = center_y_sq - (height_sq / 2.0); + int left_spritepixels = center_x - (render_w / 2.0 / zoom); + int top_spritepixels = center_y - (render_h / 2.0 / zoom); + int x_limit = left_edge + width_sq + 2; + if (x_limit > grid_data->grid_w) x_limit = grid_data->grid_w; + int y_limit = top_edge + height_sq + 2; + if (y_limit > grid_data->grid_h) y_limit = grid_data->grid_h; + + // Render layers below entities (z_index < 0) + grid_data->sortLayers(); + for (auto& layer : grid_data->layers) { + if (layer->z_index >= 0) break; + layer->render(*activeTexture, left_spritepixels, top_spritepixels, + left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); + } + + // Render entities + if (grid_data->entities) { + for (auto& e : *grid_data->entities) { + if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 || + e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) { + continue; + } + auto& drawent = e->sprite; + drawent.setScale(sf::Vector2f(zoom, zoom)); + auto pixel_pos = sf::Vector2f( + (e->position.x*cell_width - left_spritepixels + e->sprite_offset.x) * zoom, + (e->position.y*cell_height - top_spritepixels + e->sprite_offset.y) * zoom); + drawent.render(pixel_pos, *activeTexture); + } + } + + // Render layers above entities (z_index >= 0) + for (auto& layer : grid_data->layers) { + if (layer->z_index < 0) continue; + layer->render(*activeTexture, left_spritepixels, top_spritepixels, + left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); + } + + // Children (grid-world pixel coordinates) + if (grid_data->children && !grid_data->children->empty()) { + if (grid_data->children_need_sort) { + std::sort(grid_data->children->begin(), grid_data->children->end(), + [](const auto& a, const auto& b) { return a->z_index < b->z_index; }); + grid_data->children_need_sort = false; + } + for (auto& child : *grid_data->children) { + if (!child->visible) continue; + float child_grid_x = child->position.x / cell_width; + float child_grid_y = child->position.y / cell_height; + if (child_grid_x < left_edge - 2 || child_grid_x >= left_edge + width_sq + 2 || + child_grid_y < top_edge - 2 || child_grid_y >= top_edge + height_sq + 2) + continue; + auto pixel_pos = sf::Vector2f( + (child->position.x - left_spritepixels) * zoom, + (child->position.y - top_spritepixels) * zoom); + child->render(pixel_pos, *activeTexture); + } + } + + // Perspective overlay + if (perspective_enabled) { + auto entity = perspective_entity.lock(); + sf::RectangleShape overlay; + overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); + + if (entity) { + for (int x = std::max(0, (int)(left_edge - 1)); x < x_limit; x++) { + for (int y = std::max(0, (int)(top_edge - 1)); y < y_limit; y++) { + if (x < 0 || x >= grid_data->grid_w || y < 0 || y >= grid_data->grid_h) continue; + auto pixel_pos = sf::Vector2f( + (x*cell_width - left_spritepixels) * zoom, + (y*cell_height - top_spritepixels) * zoom); + int idx = y * grid_data->grid_w + x; + if (idx >= 0 && idx < static_cast(entity->gridstate.size())) { + const auto& state = entity->gridstate[idx]; + overlay.setPosition(pixel_pos); + if (!state.discovered) { + overlay.setFillColor(sf::Color(0, 0, 0, 255)); + activeTexture->draw(overlay); + } else if (!state.visible) { + overlay.setFillColor(sf::Color(32, 32, 40, 192)); + activeTexture->draw(overlay); + } + } + } + } + } else { + for (int x = std::max(0, (int)(left_edge - 1)); x < x_limit; x++) { + for (int y = std::max(0, (int)(top_edge - 1)); y < y_limit; y++) { + if (x < 0 || x >= grid_data->grid_w || y < 0 || y >= grid_data->grid_h) continue; + auto pixel_pos = sf::Vector2f( + (x*cell_width - left_spritepixels) * zoom, + (y*cell_height - top_spritepixels) * zoom); + overlay.setPosition(pixel_pos); + overlay.setFillColor(sf::Color(0, 0, 0, 255)); + activeTexture->draw(overlay); + } + } + } + } + + activeTexture->display(); + + // Camera rotation compositing + if (has_camera_rotation) { + renderTexture.clear(fill_color); + sf::Sprite rotatedSprite(rotationTexture.getTexture()); + float tex_center_x = aabb_w / 2.0f; + float tex_center_y = aabb_h / 2.0f; + rotatedSprite.setOrigin(tex_center_x, tex_center_y); + rotatedSprite.setRotation(camera_rotation); + rotatedSprite.setPosition(grid_w_px / 2.0f, grid_h_px / 2.0f); + rotatedSprite.setTextureRect(sf::IntRect(0, 0, (int)aabb_w, (int)aabb_h)); + renderTexture.draw(rotatedSprite); + renderTexture.display(); + output.setPosition(box.getPosition() + offset); + output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px)); + } + + if (rotation != 0.0f) { + output.setOrigin(origin); + output.setRotation(rotation); + output.setPosition(box.getPosition() + offset + origin); + } else { + output.setOrigin(0, 0); + output.setRotation(0); + } + + if (shader && shader->shader) { + sf::Vector2f resolution(box.getSize().x, box.getSize().y); + PyShader::applyEngineUniforms(*shader->shader, resolution); + if (uniforms) uniforms->applyTo(*shader->shader); + target.draw(output, shader->shader.get()); + } else { + target.draw(output); + } +} + +UIDrawable* UIGridView::click_at(sf::Vector2f point) +{ + if (!box.getGlobalBounds().contains(point)) return nullptr; + return this; // GridView consumes clicks within its bounds +} + +// Property system +bool UIGridView::setProperty(const std::string& name, float value) +{ + if (name == "center_x") { center_x = value; return true; } + if (name == "center_y") { center_y = value; return true; } + if (name == "zoom") { zoom = value; return true; } + if (name == "camera_rotation") { camera_rotation = value; return true; } + return UIDrawable::setProperty(name, value); +} + +bool UIGridView::setProperty(const std::string& name, const sf::Vector2f& value) +{ + return UIDrawable::setProperty(name, value); +} + +bool UIGridView::getProperty(const std::string& name, float& value) const +{ + if (name == "center_x") { value = center_x; return true; } + if (name == "center_y") { value = center_y; return true; } + if (name == "zoom") { value = zoom; return true; } + if (name == "camera_rotation") { value = camera_rotation; return true; } + return UIDrawable::getProperty(name, value); +} + +bool UIGridView::getProperty(const std::string& name, sf::Vector2f& value) const +{ + return UIDrawable::getProperty(name, value); +} + +bool UIGridView::hasProperty(const std::string& name) const +{ + if (name == "center_x" || name == "center_y" || name == "zoom" || name == "camera_rotation") + return true; + return UIDrawable::hasProperty(name); +} + +// ========================================================================= +// Python API +// ========================================================================= + +int UIGridView::init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"grid", "pos", "size", "zoom", "fill_color", "name", nullptr}; + PyObject* grid_obj = nullptr; + PyObject* pos_obj = nullptr; + PyObject* size_obj = nullptr; + float zoom_val = 1.0f; + PyObject* fill_obj = nullptr; + const char* name = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOfOz", const_cast(kwlist), + &grid_obj, &pos_obj, &size_obj, &zoom_val, &fill_obj, &name)) { + return -1; + } + + self->data->zoom = zoom_val; + if (name) self->data->UIDrawable::name = std::string(name); + + // Parse grid + if (grid_obj && grid_obj != Py_None) { + if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType)) { + PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj; + // Create aliasing shared_ptr: shares ownership with UIGrid, points to GridData base + self->data->grid_data = std::shared_ptr( + pygrid->data, static_cast(pygrid->data.get())); + self->data->ptex = pygrid->data->getTexture(); + } else { + PyErr_SetString(PyExc_TypeError, "grid must be a Grid object"); + return -1; + } + } + + // Parse pos + if (pos_obj && pos_obj != Py_None) { + sf::Vector2f pos = PyObject_to_sfVector2f(pos_obj); + if (PyErr_Occurred()) return -1; + self->data->position = pos; + self->data->box.setPosition(pos); + } + + // Parse size + if (size_obj && size_obj != Py_None) { + sf::Vector2f size = PyObject_to_sfVector2f(size_obj); + if (PyErr_Occurred()) return -1; + self->data->box.setSize(size); + } + + // Parse fill_color + if (fill_obj && fill_obj != Py_None) { + self->data->fill_color = PyColor::fromPy(fill_obj); + if (PyErr_Occurred()) return -1; + } + + // Center camera on grid if we have one + if (self->data->grid_data) { + self->data->center_camera(); + self->data->ensureRenderTextureSize(); + } + + self->weakreflist = NULL; + return 0; +} + +PyObject* UIGridView::repr(PyUIGridViewObject* self) +{ + std::ostringstream ss; + ss << "data->grid_data) { + ss << " grid=(" << self->data->grid_data->grid_w << "x" << self->data->grid_data->grid_h << ")"; + } else { + ss << " grid=None"; + } + ss << " pos=(" << self->data->box.getPosition().x << ", " << self->data->box.getPosition().y << ")" + << " size=(" << self->data->box.getSize().x << ", " << self->data->box.getSize().y << ")" + << " zoom=" << self->data->zoom << ">"; + return PyUnicode_FromString(ss.str().c_str()); +} + +// Property getters/setters +PyObject* UIGridView::get_grid(PyUIGridViewObject* self, void* closure) +{ + if (!self->data->grid_data) Py_RETURN_NONE; + // TODO: return the Grid wrapper for grid_data + Py_RETURN_NONE; +} + +int UIGridView::set_grid(PyUIGridViewObject* self, PyObject* value, void* closure) +{ + if (value == Py_None) { + self->data->grid_data = nullptr; + return 0; + } + if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) { + PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None"); + return -1; + } + PyUIGridObject* pygrid = (PyUIGridObject*)value; + self->data->grid_data = std::shared_ptr( + pygrid->data, static_cast(pygrid->data.get())); + self->data->ptex = pygrid->data->getTexture(); + return 0; +} + +PyObject* UIGridView::get_center(PyUIGridViewObject* self, void* closure) +{ + return sfVector2f_to_PyObject(sf::Vector2f(self->data->center_x, self->data->center_y)); +} + +int UIGridView::set_center(PyUIGridViewObject* self, PyObject* value, void* closure) +{ + sf::Vector2f vec = PyObject_to_sfVector2f(value); + if (PyErr_Occurred()) return -1; + self->data->center_x = vec.x; + self->data->center_y = vec.y; + return 0; +} + +PyObject* UIGridView::get_zoom(PyUIGridViewObject* self, void* closure) +{ + return PyFloat_FromDouble(self->data->zoom); +} + +int UIGridView::set_zoom(PyUIGridViewObject* self, PyObject* value, void* closure) +{ + double val = PyFloat_AsDouble(value); + if (val == -1.0 && PyErr_Occurred()) return -1; + self->data->zoom = static_cast(val); + return 0; +} + +PyObject* UIGridView::get_fill_color(PyUIGridViewObject* self, void* closure) +{ + auto type = &mcrfpydef::PyColorType; + auto obj = (PyColorObject*)type->tp_alloc(type, 0); + if (obj) obj->data = self->data->fill_color; + return (PyObject*)obj; +} + +int UIGridView::set_fill_color(PyUIGridViewObject* self, PyObject* value, void* closure) +{ + self->data->fill_color = PyColor::fromPy(value); + if (PyErr_Occurred()) return -1; + return 0; +} + +PyObject* UIGridView::get_texture(PyUIGridViewObject* self, void* closure) +{ + if (!self->data->ptex) Py_RETURN_NONE; + // TODO: return texture wrapper + Py_RETURN_NONE; +} + +// Methods and getsetters arrays +PyMethodDef UIGridView_all_methods[] = { + {NULL} +}; + +PyGetSetDef UIGridView::getsetters[] = { + {"grid", (getter)UIGridView::get_grid, (setter)UIGridView::set_grid, + "The Grid data this view renders.", NULL}, + {"center", (getter)UIGridView::get_center, (setter)UIGridView::set_center, + "Camera center point in pixel coordinates.", NULL}, + {"zoom", (getter)UIGridView::get_zoom, (setter)UIGridView::set_zoom, + "Zoom level for rendering.", NULL}, + {"fill_color", (getter)UIGridView::get_fill_color, (setter)UIGridView::set_fill_color, + "Background fill color.", NULL}, + {"texture", (getter)UIGridView::get_texture, NULL, + "Texture used for tile rendering (read-only).", NULL}, + {NULL} +}; diff --git a/src/UIGridView.h b/src/UIGridView.h new file mode 100644 index 0000000..b8f605d --- /dev/null +++ b/src/UIGridView.h @@ -0,0 +1,158 @@ +#pragma once +// UIGridView.h - Rendering view for GridData (#252) +// +// GridView is a UIDrawable that renders a GridData object. Multiple GridViews +// can reference the same GridData for split-screen, minimap, etc. +// GridView holds rendering state (camera, zoom, perspective) independently. + +#include "Common.h" +#include "Python.h" +#include "structmember.h" +#include "UIDrawable.h" +#include "UIBase.h" +#include "GridData.h" +#include "PyTexture.h" +#include "PyColor.h" +#include "PyVector.h" +#include "PyCallable.h" +#include "PyDrawable.h" + +// Forward declarations +class UIGrid; +class UIGridView; + +// Python object struct (UIGridView forward-declared above) +typedef struct { + PyObject_HEAD + std::shared_ptr data; + PyObject* weakreflist; +} PyUIGridViewObject; + +class UIGridView : public UIDrawable +{ +public: + UIGridView(); + ~UIGridView(); + + void render(sf::Vector2f offset, sf::RenderTarget& target) override final; + PyObjectsEnum derived_type() override final; + UIDrawable* click_at(sf::Vector2f point) override final; + + sf::FloatRect get_bounds() const override; + void move(float dx, float dy) override; + void resize(float w, float h) override; + + // The grid data this view renders + std::shared_ptr grid_data; + + // Rendering state (independent per view) + std::shared_ptr ptex; + sf::RectangleShape box; + float center_x = 0, center_y = 0, zoom = 1.0f; + float camera_rotation = 0.0f; + sf::Color fill_color{8, 8, 8, 255}; + + // Perspective (per-view) + std::weak_ptr perspective_entity; + bool perspective_enabled = false; + + // Render textures + sf::Sprite sprite_proto, output; + sf::RenderTexture renderTexture; + sf::Vector2u renderTextureSize{0, 0}; + void ensureRenderTextureSize(); + sf::RenderTexture rotationTexture; + unsigned int rotationTextureSize = 0; + + // Property system for animations + bool setProperty(const std::string& name, float value) override; + bool setProperty(const std::string& name, const sf::Vector2f& value) override; + bool getProperty(const std::string& name, float& value) const override; + bool getProperty(const std::string& name, sf::Vector2f& value) const override; + bool hasProperty(const std::string& name) const override; + + // Camera positioning + void center_camera(); + void center_camera(float tile_x, float tile_y); + + // Cell coordinate conversion + std::optional screenToCell(sf::Vector2f screen_pos) const; + sf::Vector2f getEffectiveCellSize() const; + + static constexpr int DEFAULT_CELL_WIDTH = 16; + static constexpr int DEFAULT_CELL_HEIGHT = 16; + + // ========================================================================= + // Python API + // ========================================================================= + static int init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds); + static PyObject* repr(PyUIGridViewObject* self); + + static PyObject* get_grid(PyUIGridViewObject* self, void* closure); + static int set_grid(PyUIGridViewObject* self, PyObject* value, void* closure); + static PyObject* get_center(PyUIGridViewObject* self, void* closure); + static int set_center(PyUIGridViewObject* self, PyObject* value, void* closure); + static PyObject* get_zoom(PyUIGridViewObject* self, void* closure); + static int set_zoom(PyUIGridViewObject* self, PyObject* value, void* closure); + static PyObject* get_fill_color(PyUIGridViewObject* self, void* closure); + static int set_fill_color(PyUIGridViewObject* self, PyObject* value, void* closure); + static PyObject* get_texture(PyUIGridViewObject* self, void* closure); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// Forward declaration of methods array +extern PyMethodDef UIGridView_all_methods[]; + +namespace mcrfpydef { + inline PyTypeObject PyUIGridViewType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.GridView", + .tp_basicsize = sizeof(PyUIGridViewObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)[](PyObject* self) + { + PyUIGridViewObject* obj = (PyUIGridViewObject*)self; + PyObject_GC_UnTrack(self); + if (obj->weakreflist != NULL) { + PyObject_ClearWeakRefs(self); + } + obj->data.reset(); + Py_TYPE(self)->tp_free(self); + }, + .tp_repr = (reprfunc)UIGridView::repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + .tp_doc = PyDoc_STR( + "GridView(grid=None, pos=None, size=None, **kwargs)\n\n" + "A rendering view for a Grid's data. Multiple GridViews can display\n" + "the same Grid with different camera positions, zoom levels, etc.\n\n" + "Args:\n" + " grid (Grid): The Grid to render. Required.\n" + " pos (tuple): Position as (x, y). Default: (0, 0)\n" + " size (tuple): Size as (w, h). Default: (256, 256)\n\n" + "Keyword Args:\n" + " zoom (float): Zoom level. Default: 1.0\n" + " center (tuple): Camera center (x, y) in pixels. Default: grid center\n" + " fill_color (Color): Background color. Default: dark gray\n"), + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + return 0; + }, + .tp_clear = [](PyObject* self) -> int { + return 0; + }, + .tp_methods = UIGridView_all_methods, + .tp_getset = UIGridView::getsetters, + .tp_base = &mcrfpydef::PyDrawableType, + .tp_init = (initproc)UIGridView::init, + .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* + { + PyUIGridViewObject* self = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (self) { + self->data = std::make_shared(); + self->weakreflist = nullptr; + } + return (PyObject*)self; + } + }; +} diff --git a/tests/unit/gridview_test.py b/tests/unit/gridview_test.py new file mode 100644 index 0000000..6983860 --- /dev/null +++ b/tests/unit/gridview_test.py @@ -0,0 +1,91 @@ +"""Unit test for GridView (#252): rendering view for Grid data.""" +import mcrfpy +import sys + +def test_gridview_creation(): + """GridView can be created with a Grid reference.""" + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) + view = mcrfpy.GridView(grid=grid, pos=(200, 0), size=(160, 160)) + assert view.zoom == 1.0 + print("PASS: GridView creation") + +def test_gridview_properties(): + """GridView properties can be read and set.""" + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) + view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(200, 200)) + + view.zoom = 2.0 + assert abs(view.zoom - 2.0) < 0.01 + + view.center = (50, 50) + c = view.center + assert abs(c.x - 50) < 0.01 and abs(c.y - 50) < 0.01 + + view.fill_color = mcrfpy.Color(255, 0, 0) + assert view.fill_color.r == 255 + print("PASS: GridView properties") + +def test_gridview_in_scene(): + """GridView can be added to a scene's children.""" + scene = mcrfpy.Scene("test_gv_scene") + mcrfpy.current_scene = scene + + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) + view = mcrfpy.GridView(grid=grid, pos=(200, 0), size=(160, 160)) + + scene.children.append(grid) + scene.children.append(view) + assert len(scene.children) == 2 + print("PASS: GridView in scene") + +def test_gridview_multi_view(): + """Multiple GridViews can reference the same Grid.""" + scene = mcrfpy.Scene("test_gv_multi") + mcrfpy.current_scene = scene + + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320)) + scene.children.append(grid) + + view1 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5) + view2 = mcrfpy.GridView(grid=grid, pos=(350, 170), size=(160, 160), zoom=2.0) + + scene.children.append(view1) + scene.children.append(view2) + + # Both views exist with different zoom + assert abs(view1.zoom - 0.5) < 0.01 + assert abs(view2.zoom - 2.0) < 0.01 + assert len(scene.children) == 3 + print("PASS: Multiple GridViews of same Grid") + +def test_gridview_repr(): + """GridView has useful repr.""" + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160)) + view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(240, 160)) + r = repr(view) + assert "GridView" in r + assert "15x10" in r + print("PASS: GridView repr") + +def test_gridview_no_grid(): + """GridView without a grid doesn't crash.""" + view = mcrfpy.GridView() + assert view.grid is None + r = repr(view) + assert "None" in r + print("PASS: GridView without grid") + +if __name__ == "__main__": + test_gridview_creation() + test_gridview_properties() + test_gridview_in_scene() + test_gridview_multi_view() + test_gridview_repr() + test_gridview_no_grid() + print("All GridView tests passed") + sys.exit(0)