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) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-16 08:41:44 -04:00
commit 4b13e5f5db
10 changed files with 824 additions and 2 deletions

View file

@ -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<UIDrawable> 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<UIGridView>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UILINE:
{
type = &mcrfpydef::PyUILineType;

View file

@ -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";

View file

@ -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<std::shared_ptr<UIDrawable>>* collect
}
break;
}
case PyObjectsEnum::UIGRIDVIEW: {
auto gridview = std::static_pointer_cast<UIGridView>(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;
}

View file

@ -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

View file

@ -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<UIDrawable> 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<UIGridView>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UILINE:
{
type = &PyUILineType;
@ -152,6 +164,8 @@ static std::shared_ptr<UIDrawable> 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;
}
}

View file

@ -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<UIGrid>(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<UIGridView>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
default:
Py_RETURN_NONE;
}

View file

@ -33,7 +33,8 @@ enum PyObjectsEnum : int
UILINE,
UICIRCLE,
UIARC,
UIVIEWPORT3D
UIVIEWPORT3D,
UIGRIDVIEW // #252: rendering view for GridData
};
class UIDrawable

516
src/UIGridView.cpp Normal file
View file

@ -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 <cmath>
#include <algorithm>
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<sf::Vector2i> 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<int>(std::floor(gx));
int cy = static_cast<int>(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<unsigned int>(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<int>(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<char**>(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<GridData>(
pygrid->data, static_cast<GridData*>(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 << "<GridView";
if (self->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<GridData>(
pygrid->data, static_cast<GridData*>(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<float>(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}
};

158
src/UIGridView.h Normal file
View file

@ -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<UIGridView> 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<GridData> grid_data;
// Rendering state (independent per view)
std::shared_ptr<PyTexture> 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<UIEntity> 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<sf::Vector2i> 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<UIGridView>();
self->weakreflist = nullptr;
}
return (PyObject*)self;
}
};
}

View file

@ -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)