- #212: Add GRID_MAX (8192) validation to Grid, ColorLayer, TileLayer - #213: Validate color components are in 0-255 range - #214: Add null pointer checks before HeightMap operations - #216: Change entities_in_radius(x, y, radius) to (pos, radius) - #217: Fix Entity __repr__ to show actual draw_pos float values Closes #212, closes #213, closes #214, closes #216, closes #217 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2113 lines
74 KiB
C++
2113 lines
74 KiB
C++
#include "GridLayers.h"
|
|
#include "UIGrid.h"
|
|
#include "UIEntity.h"
|
|
#include "PyColor.h"
|
|
#include "PyTexture.h"
|
|
#include "PyFOV.h"
|
|
#include "PyPositionHelper.h"
|
|
#include "PyHeightMap.h"
|
|
#include <sstream>
|
|
|
|
// =============================================================================
|
|
// HeightMap helper functions for layer operations
|
|
// =============================================================================
|
|
|
|
// Helper to parse a range tuple (min, max) and validate
|
|
static bool ParseRange(PyObject* range_obj, float* out_min, float* out_max, const char* arg_name) {
|
|
if (!PyTuple_Check(range_obj) && !PyList_Check(range_obj)) {
|
|
PyErr_Format(PyExc_TypeError, "%s must be a (min, max) tuple or list", arg_name);
|
|
return false;
|
|
}
|
|
|
|
PyObject* seq = PySequence_Fast(range_obj, "range must be sequence");
|
|
if (!seq) return false;
|
|
|
|
if (PySequence_Fast_GET_SIZE(seq) != 2) {
|
|
Py_DECREF(seq);
|
|
PyErr_Format(PyExc_ValueError, "%s must have exactly 2 elements (min, max)", arg_name);
|
|
return false;
|
|
}
|
|
|
|
*out_min = (float)PyFloat_AsDouble(PySequence_Fast_GET_ITEM(seq, 0));
|
|
*out_max = (float)PyFloat_AsDouble(PySequence_Fast_GET_ITEM(seq, 1));
|
|
Py_DECREF(seq);
|
|
|
|
if (PyErr_Occurred()) return false;
|
|
|
|
if (*out_min > *out_max) {
|
|
// Build error message manually since PyErr_Format has limited float support
|
|
char buf[256];
|
|
snprintf(buf, sizeof(buf), "%s: min (%.3f) must be <= max (%.3f)",
|
|
arg_name, *out_min, *out_max);
|
|
PyErr_SetString(PyExc_ValueError, buf);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Helper to validate HeightMap matches layer dimensions
|
|
static bool ValidateHeightMapSize(PyHeightMapObject* hmap, int grid_x, int grid_y) {
|
|
int hmap_width = hmap->heightmap->w;
|
|
int hmap_height = hmap->heightmap->h;
|
|
|
|
if (hmap_width != grid_x || hmap_height != grid_y) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"HeightMap size (%d, %d) does not match layer size (%d, %d)",
|
|
hmap_width, hmap_height, grid_x, grid_y);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Helper to check if an object is a HeightMap (runtime lookup to avoid static type issues)
|
|
static bool IsHeightMapObject(PyObject* obj, PyHeightMapObject** out_hmap) {
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return false;
|
|
|
|
auto* heightmap_type = PyObject_GetAttrString(mcrfpy_module, "HeightMap");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!heightmap_type) return false;
|
|
|
|
bool result = PyObject_IsInstance(obj, heightmap_type);
|
|
Py_DECREF(heightmap_type);
|
|
|
|
if (result && out_hmap) {
|
|
*out_hmap = (PyHeightMapObject*)obj;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Helper to parse a color from Python object
|
|
static bool ParseColorArg(PyObject* obj, sf::Color& out_color, const char* arg_name) {
|
|
if (!obj || obj == Py_None) {
|
|
PyErr_Format(PyExc_TypeError, "%s cannot be None", arg_name);
|
|
return false;
|
|
}
|
|
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return false;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return false;
|
|
|
|
if (PyObject_IsInstance(obj, color_type)) {
|
|
out_color = ((PyColorObject*)obj)->data;
|
|
Py_DECREF(color_type);
|
|
return true;
|
|
}
|
|
Py_DECREF(color_type);
|
|
|
|
if (PyTuple_Check(obj) || PyList_Check(obj)) {
|
|
PyObject* seq = PySequence_Fast(obj, "color must be sequence");
|
|
if (!seq) return false;
|
|
|
|
Py_ssize_t len = PySequence_Fast_GET_SIZE(seq);
|
|
if (len < 3 || len > 4) {
|
|
Py_DECREF(seq);
|
|
PyErr_Format(PyExc_ValueError, "%s must be (r, g, b) or (r, g, b, a)", arg_name);
|
|
return false;
|
|
}
|
|
|
|
int r = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 0));
|
|
int g = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 1));
|
|
int b = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 2));
|
|
int a = (len == 4) ? (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 3)) : 255;
|
|
Py_DECREF(seq);
|
|
|
|
if (PyErr_Occurred()) return false;
|
|
|
|
// #213 - Validate color component range (0-255)
|
|
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 255) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"%s color components must be in range 0-255, got (%d, %d, %d, %d)",
|
|
arg_name, r, g, b, a);
|
|
return false;
|
|
}
|
|
|
|
out_color = sf::Color(r, g, b, a);
|
|
return true;
|
|
}
|
|
|
|
PyErr_Format(PyExc_TypeError, "%s must be a Color or (r, g, b[, a]) tuple", arg_name);
|
|
return false;
|
|
}
|
|
|
|
// Interpolate between two colors
|
|
static sf::Color LerpColor(const sf::Color& a, const sf::Color& b, float t) {
|
|
t = std::max(0.0f, std::min(1.0f, t)); // Clamp t to [0, 1]
|
|
return sf::Color(
|
|
static_cast<sf::Uint8>(a.r + (b.r - a.r) * t),
|
|
static_cast<sf::Uint8>(a.g + (b.g - a.g) * t),
|
|
static_cast<sf::Uint8>(a.b + (b.b - a.b) * t),
|
|
static_cast<sf::Uint8>(a.a + (b.a - a.a) * t)
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// GridLayer base class
|
|
// =============================================================================
|
|
|
|
GridLayer::GridLayer(GridLayerType type, int z_index, int grid_x, int grid_y, UIGrid* parent)
|
|
: type(type), z_index(z_index), grid_x(grid_x), grid_y(grid_y),
|
|
parent_grid(parent), visible(true),
|
|
chunks_x(0), chunks_y(0),
|
|
cached_cell_width(0), cached_cell_height(0)
|
|
{
|
|
initChunks();
|
|
}
|
|
|
|
void GridLayer::initChunks() {
|
|
// Calculate chunk dimensions
|
|
chunks_x = (grid_x + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
|
chunks_y = (grid_y + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
|
int total_chunks = chunks_x * chunks_y;
|
|
|
|
// Initialize per-chunk tracking
|
|
chunk_dirty.assign(total_chunks, true); // All chunks start dirty
|
|
chunk_texture_initialized.assign(total_chunks, false);
|
|
chunk_textures.clear();
|
|
chunk_textures.reserve(total_chunks);
|
|
for (int i = 0; i < total_chunks; ++i) {
|
|
chunk_textures.push_back(std::make_unique<sf::RenderTexture>());
|
|
}
|
|
}
|
|
|
|
void GridLayer::markDirty() {
|
|
// Mark ALL chunks as dirty
|
|
std::fill(chunk_dirty.begin(), chunk_dirty.end(), true);
|
|
}
|
|
|
|
void GridLayer::markDirty(int cell_x, int cell_y) {
|
|
// Mark only the specific chunk containing this cell
|
|
if (cell_x < 0 || cell_x >= grid_x || cell_y < 0 || cell_y >= grid_y) return;
|
|
|
|
int chunk_idx = getChunkIndex(cell_x, cell_y);
|
|
if (chunk_idx >= 0 && chunk_idx < static_cast<int>(chunk_dirty.size())) {
|
|
chunk_dirty[chunk_idx] = true;
|
|
}
|
|
}
|
|
|
|
int GridLayer::getChunkIndex(int cell_x, int cell_y) const {
|
|
int cx = cell_x / CHUNK_SIZE;
|
|
int cy = cell_y / CHUNK_SIZE;
|
|
return cy * chunks_x + cx;
|
|
}
|
|
|
|
void GridLayer::getChunkBounds(int chunk_x, int chunk_y, int& start_x, int& start_y, int& end_x, int& end_y) const {
|
|
start_x = chunk_x * CHUNK_SIZE;
|
|
start_y = chunk_y * CHUNK_SIZE;
|
|
end_x = std::min(start_x + CHUNK_SIZE, grid_x);
|
|
end_y = std::min(start_y + CHUNK_SIZE, grid_y);
|
|
}
|
|
|
|
void GridLayer::ensureChunkTexture(int chunk_idx, int cell_width, int cell_height) {
|
|
if (chunk_idx < 0 || chunk_idx >= static_cast<int>(chunk_textures.size())) return;
|
|
if (!chunk_textures[chunk_idx]) return;
|
|
|
|
// Calculate chunk dimensions in cells
|
|
int cx = chunk_idx % chunks_x;
|
|
int cy = chunk_idx / chunks_x;
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(cx, cy, start_x, start_y, end_x, end_y);
|
|
|
|
int chunk_width_cells = end_x - start_x;
|
|
int chunk_height_cells = end_y - start_y;
|
|
|
|
unsigned int required_width = chunk_width_cells * cell_width;
|
|
unsigned int required_height = chunk_height_cells * cell_height;
|
|
|
|
// Check if texture needs (re)creation
|
|
if (chunk_texture_initialized[chunk_idx] &&
|
|
chunk_textures[chunk_idx]->getSize().x == required_width &&
|
|
chunk_textures[chunk_idx]->getSize().y == required_height &&
|
|
cached_cell_width == cell_width &&
|
|
cached_cell_height == cell_height) {
|
|
return; // Already properly sized
|
|
}
|
|
|
|
// Create the texture for this chunk
|
|
if (!chunk_textures[chunk_idx]->create(required_width, required_height)) {
|
|
chunk_texture_initialized[chunk_idx] = false;
|
|
return;
|
|
}
|
|
|
|
chunk_texture_initialized[chunk_idx] = true;
|
|
chunk_dirty[chunk_idx] = true; // Force re-render after resize
|
|
cached_cell_width = cell_width;
|
|
cached_cell_height = cell_height;
|
|
}
|
|
|
|
// =============================================================================
|
|
// ColorLayer implementation
|
|
// =============================================================================
|
|
|
|
ColorLayer::ColorLayer(int z_index, int grid_x, int grid_y, UIGrid* parent)
|
|
: GridLayer(GridLayerType::Color, z_index, grid_x, grid_y, parent),
|
|
colors(grid_x * grid_y, sf::Color::Transparent),
|
|
perspective_visible(255, 255, 200, 64),
|
|
perspective_discovered(100, 100, 100, 128),
|
|
perspective_unknown(0, 0, 0, 255),
|
|
has_perspective(false)
|
|
{}
|
|
|
|
sf::Color& ColorLayer::at(int x, int y) {
|
|
return colors[y * grid_x + x];
|
|
}
|
|
|
|
const sf::Color& ColorLayer::at(int x, int y) const {
|
|
return colors[y * grid_x + x];
|
|
}
|
|
|
|
void ColorLayer::fill(const sf::Color& color) {
|
|
std::fill(colors.begin(), colors.end(), color);
|
|
markDirty(); // Mark ALL chunks for re-render
|
|
}
|
|
|
|
void ColorLayer::fillRect(int x, int y, int width, int height, const sf::Color& color) {
|
|
// Clamp to valid bounds
|
|
int x1 = std::max(0, x);
|
|
int y1 = std::max(0, y);
|
|
int x2 = std::min(grid_x, x + width);
|
|
int y2 = std::min(grid_y, y + height);
|
|
|
|
// Fill the rectangle
|
|
for (int fy = y1; fy < y2; ++fy) {
|
|
for (int fx = x1; fx < x2; ++fx) {
|
|
colors[fy * grid_x + fx] = color;
|
|
}
|
|
}
|
|
|
|
// Mark affected chunks dirty
|
|
int chunk_x1 = x1 / CHUNK_SIZE;
|
|
int chunk_y1 = y1 / CHUNK_SIZE;
|
|
int chunk_x2 = (x2 - 1) / CHUNK_SIZE;
|
|
int chunk_y2 = (y2 - 1) / CHUNK_SIZE;
|
|
|
|
for (int cy = chunk_y1; cy <= chunk_y2; ++cy) {
|
|
for (int cx = chunk_x1; cx <= chunk_x2; ++cx) {
|
|
int idx = cy * chunks_x + cx;
|
|
if (idx >= 0 && idx < static_cast<int>(chunk_dirty.size())) {
|
|
chunk_dirty[idx] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void ColorLayer::drawFOV(int source_x, int source_y, int radius,
|
|
TCOD_fov_algorithm_t algorithm,
|
|
const sf::Color& visible_color,
|
|
const sf::Color& discovered_color,
|
|
const sf::Color& unknown_color) {
|
|
// Need parent grid for TCOD map access
|
|
if (!parent_grid) {
|
|
return; // Cannot compute FOV without parent grid
|
|
}
|
|
|
|
// Import UIGrid here to avoid circular dependency in header
|
|
// parent_grid is already a UIGrid*, we can use its tcod_map directly
|
|
// But we need to forward declare access to it...
|
|
|
|
// Compute FOV on the parent grid
|
|
parent_grid->computeFOV(source_x, source_y, radius, true, algorithm);
|
|
|
|
// Paint cells based on visibility
|
|
for (int cy = 0; cy < grid_y; ++cy) {
|
|
for (int cx = 0; cx < grid_x; ++cx) {
|
|
// Check if in FOV (visible right now)
|
|
if (parent_grid->isInFOV(cx, cy)) {
|
|
colors[cy * grid_x + cx] = visible_color;
|
|
}
|
|
// Check if previously discovered (current color != unknown)
|
|
else if (colors[cy * grid_x + cx] != unknown_color) {
|
|
colors[cy * grid_x + cx] = discovered_color;
|
|
}
|
|
// Otherwise leave as unknown (or set to unknown if first time)
|
|
else {
|
|
colors[cy * grid_x + cx] = unknown_color;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark entire layer dirty
|
|
markDirty();
|
|
}
|
|
|
|
void ColorLayer::applyPerspective(std::shared_ptr<UIEntity> entity,
|
|
const sf::Color& visible,
|
|
const sf::Color& discovered,
|
|
const sf::Color& unknown) {
|
|
perspective_entity = entity;
|
|
perspective_visible = visible;
|
|
perspective_discovered = discovered;
|
|
perspective_unknown = unknown;
|
|
has_perspective = true;
|
|
|
|
// Initial draw based on entity's current position
|
|
updatePerspective();
|
|
}
|
|
|
|
void ColorLayer::updatePerspective() {
|
|
if (!has_perspective) return;
|
|
|
|
auto entity = perspective_entity.lock();
|
|
if (!entity) {
|
|
// Entity was deleted, clear perspective
|
|
has_perspective = false;
|
|
return;
|
|
}
|
|
|
|
if (!parent_grid) return;
|
|
|
|
// Get entity position and grid's FOV settings
|
|
int source_x = static_cast<int>(entity->position.x);
|
|
int source_y = static_cast<int>(entity->position.y);
|
|
int radius = parent_grid->fov_radius;
|
|
TCOD_fov_algorithm_t algorithm = parent_grid->fov_algorithm;
|
|
|
|
// Use drawFOV with our stored colors
|
|
drawFOV(source_x, source_y, radius, algorithm,
|
|
perspective_visible, perspective_discovered, perspective_unknown);
|
|
}
|
|
|
|
void ColorLayer::clearPerspective() {
|
|
perspective_entity.reset();
|
|
has_perspective = false;
|
|
}
|
|
|
|
void ColorLayer::resize(int new_grid_x, int new_grid_y) {
|
|
std::vector<sf::Color> new_colors(new_grid_x * new_grid_y, sf::Color::Transparent);
|
|
|
|
// Copy existing data
|
|
int copy_x = std::min(grid_x, new_grid_x);
|
|
int copy_y = std::min(grid_y, new_grid_y);
|
|
for (int y = 0; y < copy_y; ++y) {
|
|
for (int x = 0; x < copy_x; ++x) {
|
|
new_colors[y * new_grid_x + x] = colors[y * grid_x + x];
|
|
}
|
|
}
|
|
|
|
colors = std::move(new_colors);
|
|
grid_x = new_grid_x;
|
|
grid_y = new_grid_y;
|
|
|
|
// Reinitialize chunks for new dimensions
|
|
initChunks();
|
|
}
|
|
|
|
// Render a single chunk to its cached texture
|
|
void ColorLayer::renderChunkToTexture(int chunk_x, int chunk_y, int cell_width, int cell_height) {
|
|
int chunk_idx = chunk_y * chunks_x + chunk_x;
|
|
if (chunk_idx < 0 || chunk_idx >= static_cast<int>(chunk_textures.size())) return;
|
|
if (!chunk_textures[chunk_idx]) return;
|
|
|
|
ensureChunkTexture(chunk_idx, cell_width, cell_height);
|
|
if (!chunk_texture_initialized[chunk_idx]) return;
|
|
|
|
// Get chunk bounds
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(chunk_x, chunk_y, start_x, start_y, end_x, end_y);
|
|
|
|
chunk_textures[chunk_idx]->clear(sf::Color::Transparent);
|
|
|
|
sf::RectangleShape rect;
|
|
rect.setSize(sf::Vector2f(cell_width, cell_height));
|
|
rect.setOutlineThickness(0);
|
|
|
|
// Render only cells within this chunk (local coordinates in texture)
|
|
for (int x = start_x; x < end_x; ++x) {
|
|
for (int y = start_y; y < end_y; ++y) {
|
|
const sf::Color& color = at(x, y);
|
|
if (color.a == 0) continue; // Skip fully transparent
|
|
|
|
// Position relative to chunk origin
|
|
rect.setPosition(sf::Vector2f((x - start_x) * cell_width, (y - start_y) * cell_height));
|
|
rect.setFillColor(color);
|
|
chunk_textures[chunk_idx]->draw(rect);
|
|
}
|
|
}
|
|
|
|
chunk_textures[chunk_idx]->display();
|
|
chunk_dirty[chunk_idx] = false;
|
|
}
|
|
|
|
// Legacy: render all chunks (used by fill, resize, etc.)
|
|
void ColorLayer::renderToTexture(int cell_width, int cell_height) {
|
|
for (int cy = 0; cy < chunks_y; ++cy) {
|
|
for (int cx = 0; cx < chunks_x; ++cx) {
|
|
renderChunkToTexture(cx, cy, cell_width, cell_height);
|
|
}
|
|
}
|
|
}
|
|
|
|
void ColorLayer::render(sf::RenderTarget& target,
|
|
float left_spritepixels, float top_spritepixels,
|
|
int left_edge, int top_edge, int x_limit, int y_limit,
|
|
float zoom, int cell_width, int cell_height) {
|
|
if (!visible) return;
|
|
|
|
// Calculate visible chunk range
|
|
int chunk_left = std::max(0, left_edge / CHUNK_SIZE);
|
|
int chunk_top = std::max(0, top_edge / CHUNK_SIZE);
|
|
int chunk_right = std::min(chunks_x - 1, (x_limit + CHUNK_SIZE - 1) / CHUNK_SIZE);
|
|
int chunk_bottom = std::min(chunks_y - 1, (y_limit + CHUNK_SIZE - 1) / CHUNK_SIZE);
|
|
|
|
// Iterate only over visible chunks
|
|
for (int cy = chunk_top; cy <= chunk_bottom; ++cy) {
|
|
for (int cx = chunk_left; cx <= chunk_right; ++cx) {
|
|
int chunk_idx = cy * chunks_x + cx;
|
|
|
|
// Re-render chunk only if dirty AND visible
|
|
if (chunk_dirty[chunk_idx] || !chunk_texture_initialized[chunk_idx]) {
|
|
renderChunkToTexture(cx, cy, cell_width, cell_height);
|
|
}
|
|
|
|
if (!chunk_texture_initialized[chunk_idx]) {
|
|
// Fallback: direct rendering for this chunk
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(cx, cy, start_x, start_y, end_x, end_y);
|
|
|
|
sf::RectangleShape rect;
|
|
rect.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
|
|
rect.setOutlineThickness(0);
|
|
|
|
for (int x = start_x; x < end_x; ++x) {
|
|
for (int y = start_y; y < end_y; ++y) {
|
|
const sf::Color& color = at(x, y);
|
|
if (color.a == 0) continue;
|
|
|
|
auto pixel_pos = sf::Vector2f(
|
|
(x * cell_width - left_spritepixels) * zoom,
|
|
(y * cell_height - top_spritepixels) * zoom
|
|
);
|
|
rect.setPosition(pixel_pos);
|
|
rect.setFillColor(color);
|
|
target.draw(rect);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Blit this chunk's texture to target
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(cx, cy, start_x, start_y, end_x, end_y);
|
|
|
|
// Chunk position in world pixel coordinates
|
|
float chunk_world_x = start_x * cell_width;
|
|
float chunk_world_y = start_y * cell_height;
|
|
|
|
// Position in target (accounting for viewport offset and zoom)
|
|
float dest_x = (chunk_world_x - left_spritepixels) * zoom;
|
|
float dest_y = (chunk_world_y - top_spritepixels) * zoom;
|
|
|
|
sf::Sprite chunk_sprite(chunk_textures[chunk_idx]->getTexture());
|
|
chunk_sprite.setPosition(sf::Vector2f(dest_x, dest_y));
|
|
chunk_sprite.setScale(sf::Vector2f(zoom, zoom));
|
|
|
|
target.draw(chunk_sprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TileLayer implementation
|
|
// =============================================================================
|
|
|
|
TileLayer::TileLayer(int z_index, int grid_x, int grid_y, UIGrid* parent,
|
|
std::shared_ptr<PyTexture> texture)
|
|
: GridLayer(GridLayerType::Tile, z_index, grid_x, grid_y, parent),
|
|
tiles(grid_x * grid_y, -1), // -1 = no tile
|
|
texture(texture)
|
|
{}
|
|
|
|
int& TileLayer::at(int x, int y) {
|
|
return tiles[y * grid_x + x];
|
|
}
|
|
|
|
int TileLayer::at(int x, int y) const {
|
|
return tiles[y * grid_x + x];
|
|
}
|
|
|
|
void TileLayer::fill(int tile_index) {
|
|
std::fill(tiles.begin(), tiles.end(), tile_index);
|
|
markDirty(); // Mark ALL chunks for re-render
|
|
}
|
|
|
|
void TileLayer::fillRect(int x, int y, int width, int height, int tile_index) {
|
|
// Clamp to valid bounds
|
|
int x1 = std::max(0, x);
|
|
int y1 = std::max(0, y);
|
|
int x2 = std::min(grid_x, x + width);
|
|
int y2 = std::min(grid_y, y + height);
|
|
|
|
// Fill the rectangle
|
|
for (int fy = y1; fy < y2; ++fy) {
|
|
for (int fx = x1; fx < x2; ++fx) {
|
|
tiles[fy * grid_x + fx] = tile_index;
|
|
}
|
|
}
|
|
|
|
// Mark affected chunks dirty
|
|
int chunk_x1 = x1 / CHUNK_SIZE;
|
|
int chunk_y1 = y1 / CHUNK_SIZE;
|
|
int chunk_x2 = (x2 - 1) / CHUNK_SIZE;
|
|
int chunk_y2 = (y2 - 1) / CHUNK_SIZE;
|
|
|
|
for (int cy = chunk_y1; cy <= chunk_y2; ++cy) {
|
|
for (int cx = chunk_x1; cx <= chunk_x2; ++cx) {
|
|
int idx = cy * chunks_x + cx;
|
|
if (idx >= 0 && idx < static_cast<int>(chunk_dirty.size())) {
|
|
chunk_dirty[idx] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void TileLayer::resize(int new_grid_x, int new_grid_y) {
|
|
std::vector<int> new_tiles(new_grid_x * new_grid_y, -1);
|
|
|
|
// Copy existing data
|
|
int copy_x = std::min(grid_x, new_grid_x);
|
|
int copy_y = std::min(grid_y, new_grid_y);
|
|
for (int y = 0; y < copy_y; ++y) {
|
|
for (int x = 0; x < copy_x; ++x) {
|
|
new_tiles[y * new_grid_x + x] = tiles[y * grid_x + x];
|
|
}
|
|
}
|
|
|
|
tiles = std::move(new_tiles);
|
|
grid_x = new_grid_x;
|
|
grid_y = new_grid_y;
|
|
|
|
// Reinitialize chunks for new dimensions
|
|
initChunks();
|
|
}
|
|
|
|
// Render a single chunk to its cached texture
|
|
void TileLayer::renderChunkToTexture(int chunk_x, int chunk_y, int cell_width, int cell_height) {
|
|
if (!texture) return;
|
|
|
|
int chunk_idx = chunk_y * chunks_x + chunk_x;
|
|
if (chunk_idx < 0 || chunk_idx >= static_cast<int>(chunk_textures.size())) return;
|
|
if (!chunk_textures[chunk_idx]) return;
|
|
|
|
ensureChunkTexture(chunk_idx, cell_width, cell_height);
|
|
if (!chunk_texture_initialized[chunk_idx]) return;
|
|
|
|
// Get chunk bounds
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(chunk_x, chunk_y, start_x, start_y, end_x, end_y);
|
|
|
|
chunk_textures[chunk_idx]->clear(sf::Color::Transparent);
|
|
|
|
// Render only tiles within this chunk (local coordinates in texture)
|
|
for (int x = start_x; x < end_x; ++x) {
|
|
for (int y = start_y; y < end_y; ++y) {
|
|
int tile_index = at(x, y);
|
|
if (tile_index < 0) continue; // No tile
|
|
|
|
// Position relative to chunk origin
|
|
auto pixel_pos = sf::Vector2f((x - start_x) * cell_width, (y - start_y) * cell_height);
|
|
sf::Sprite sprite = texture->sprite(tile_index, pixel_pos, sf::Vector2f(1.0f, 1.0f));
|
|
chunk_textures[chunk_idx]->draw(sprite);
|
|
}
|
|
}
|
|
|
|
chunk_textures[chunk_idx]->display();
|
|
chunk_dirty[chunk_idx] = false;
|
|
}
|
|
|
|
// Legacy: render all chunks (used by fill, resize, etc.)
|
|
void TileLayer::renderToTexture(int cell_width, int cell_height) {
|
|
for (int cy = 0; cy < chunks_y; ++cy) {
|
|
for (int cx = 0; cx < chunks_x; ++cx) {
|
|
renderChunkToTexture(cx, cy, cell_width, cell_height);
|
|
}
|
|
}
|
|
}
|
|
|
|
void TileLayer::render(sf::RenderTarget& target,
|
|
float left_spritepixels, float top_spritepixels,
|
|
int left_edge, int top_edge, int x_limit, int y_limit,
|
|
float zoom, int cell_width, int cell_height) {
|
|
if (!visible || !texture) return;
|
|
|
|
// Calculate visible chunk range
|
|
int chunk_left = std::max(0, left_edge / CHUNK_SIZE);
|
|
int chunk_top = std::max(0, top_edge / CHUNK_SIZE);
|
|
int chunk_right = std::min(chunks_x - 1, (x_limit + CHUNK_SIZE - 1) / CHUNK_SIZE);
|
|
int chunk_bottom = std::min(chunks_y - 1, (y_limit + CHUNK_SIZE - 1) / CHUNK_SIZE);
|
|
|
|
// Iterate only over visible chunks
|
|
for (int cy = chunk_top; cy <= chunk_bottom; ++cy) {
|
|
for (int cx = chunk_left; cx <= chunk_right; ++cx) {
|
|
int chunk_idx = cy * chunks_x + cx;
|
|
|
|
// Re-render chunk only if dirty AND visible
|
|
if (chunk_dirty[chunk_idx] || !chunk_texture_initialized[chunk_idx]) {
|
|
renderChunkToTexture(cx, cy, cell_width, cell_height);
|
|
}
|
|
|
|
if (!chunk_texture_initialized[chunk_idx]) {
|
|
// Fallback: direct rendering for this chunk
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(cx, cy, start_x, start_y, end_x, end_y);
|
|
|
|
for (int x = start_x; x < end_x; ++x) {
|
|
for (int y = start_y; y < end_y; ++y) {
|
|
int tile_index = at(x, y);
|
|
if (tile_index < 0) continue;
|
|
|
|
auto pixel_pos = sf::Vector2f(
|
|
(x * cell_width - left_spritepixels) * zoom,
|
|
(y * cell_height - top_spritepixels) * zoom
|
|
);
|
|
sf::Sprite sprite = texture->sprite(tile_index, pixel_pos, sf::Vector2f(zoom, zoom));
|
|
target.draw(sprite);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Blit this chunk's texture to target
|
|
int start_x, start_y, end_x, end_y;
|
|
getChunkBounds(cx, cy, start_x, start_y, end_x, end_y);
|
|
|
|
// Chunk position in world pixel coordinates
|
|
float chunk_world_x = start_x * cell_width;
|
|
float chunk_world_y = start_y * cell_height;
|
|
|
|
// Position in target (accounting for viewport offset and zoom)
|
|
float dest_x = (chunk_world_x - left_spritepixels) * zoom;
|
|
float dest_y = (chunk_world_y - top_spritepixels) * zoom;
|
|
|
|
sf::Sprite chunk_sprite(chunk_textures[chunk_idx]->getTexture());
|
|
chunk_sprite.setPosition(sf::Vector2f(dest_x, dest_y));
|
|
chunk_sprite.setScale(sf::Vector2f(zoom, zoom));
|
|
|
|
target.draw(chunk_sprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Python API - ColorLayer
|
|
// =============================================================================
|
|
|
|
PyMethodDef PyGridLayerAPI::ColorLayer_methods[] = {
|
|
{"at", (PyCFunction)PyGridLayerAPI::ColorLayer_at, METH_VARARGS | METH_KEYWORDS,
|
|
"at(pos) -> Color\nat(x, y) -> Color\n\n"
|
|
"Get the color at cell position.\n\n"
|
|
"Args:\n"
|
|
" pos: Position as (x, y) tuple, list, or Vector\n"
|
|
" x, y: Position as separate integer arguments"},
|
|
{"set", (PyCFunction)PyGridLayerAPI::ColorLayer_set, METH_VARARGS,
|
|
"set(pos, color)\n\n"
|
|
"Set the color at cell position.\n\n"
|
|
"Args:\n"
|
|
" pos: Position as (x, y) tuple, list, or Vector\n"
|
|
" color: Color object or (r, g, b[, a]) tuple"},
|
|
{"fill", (PyCFunction)PyGridLayerAPI::ColorLayer_fill, METH_VARARGS,
|
|
"fill(color)\n\nFill the entire layer with the specified color."},
|
|
{"fill_rect", (PyCFunction)PyGridLayerAPI::ColorLayer_fill_rect, METH_VARARGS | METH_KEYWORDS,
|
|
"fill_rect(pos, size, color)\n\n"
|
|
"Fill a rectangular region with a color.\n\n"
|
|
"Args:\n"
|
|
" pos (tuple): Top-left corner as (x, y)\n"
|
|
" size (tuple): Dimensions as (width, height)\n"
|
|
" color: Color object or (r, g, b[, a]) tuple"},
|
|
{"draw_fov", (PyCFunction)PyGridLayerAPI::ColorLayer_draw_fov, METH_VARARGS | METH_KEYWORDS,
|
|
"draw_fov(source, radius=None, fov=None, visible=None, discovered=None, unknown=None)\n\n"
|
|
"Paint cells based on field-of-view visibility from source position.\n\n"
|
|
"Args:\n"
|
|
" source (tuple): FOV origin as (x, y)\n"
|
|
" radius (int): FOV radius. Default: grid's fov_radius\n"
|
|
" fov (FOV): FOV algorithm. Default: grid's fov setting\n"
|
|
" visible (Color): Color for currently visible cells\n"
|
|
" discovered (Color): Color for previously seen cells\n"
|
|
" unknown (Color): Color for never-seen cells\n\n"
|
|
"Note: Layer must be attached to a grid for FOV calculation."},
|
|
{"apply_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_perspective, METH_VARARGS | METH_KEYWORDS,
|
|
"apply_perspective(entity, visible=None, discovered=None, unknown=None)\n\n"
|
|
"Bind this layer to an entity for automatic FOV updates.\n\n"
|
|
"Args:\n"
|
|
" entity (Entity): The entity whose perspective to track\n"
|
|
" visible (Color): Color for currently visible cells\n"
|
|
" discovered (Color): Color for previously seen cells\n"
|
|
" unknown (Color): Color for never-seen cells\n\n"
|
|
"After binding, call update_perspective() when the entity moves."},
|
|
{"update_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_update_perspective, METH_NOARGS,
|
|
"update_perspective()\n\n"
|
|
"Redraw FOV based on the bound entity's current position.\n\n"
|
|
"Call this after the entity moves to update the visibility layer."},
|
|
{"clear_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_clear_perspective, METH_NOARGS,
|
|
"clear_perspective()\n\n"
|
|
"Remove the perspective binding from this layer."},
|
|
{"apply_threshold", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_hmap_threshold, METH_VARARGS | METH_KEYWORDS,
|
|
"apply_threshold(source, range, color) -> ColorLayer\n\n"
|
|
"Set fixed color for cells where HeightMap value is within range.\n\n"
|
|
"Args:\n"
|
|
" source (HeightMap): Source heightmap (must match layer dimensions)\n"
|
|
" range (tuple): Value range as (min, max) inclusive\n"
|
|
" color: Color or (r, g, b[, a]) tuple to set for cells in range\n\n"
|
|
"Returns:\n"
|
|
" self for method chaining\n\n"
|
|
"Example:\n"
|
|
" layer.apply_threshold(terrain, (0.0, 0.3), (0, 0, 180)) # Blue for water"},
|
|
{"apply_gradient", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_gradient, METH_VARARGS | METH_KEYWORDS,
|
|
"apply_gradient(source, range, color_low, color_high) -> ColorLayer\n\n"
|
|
"Interpolate between colors based on HeightMap value within range.\n\n"
|
|
"Args:\n"
|
|
" source (HeightMap): Source heightmap (must match layer dimensions)\n"
|
|
" range (tuple): Value range as (min, max) inclusive\n"
|
|
" color_low: Color at range minimum\n"
|
|
" color_high: Color at range maximum\n\n"
|
|
"Returns:\n"
|
|
" self for method chaining\n\n"
|
|
"Note:\n"
|
|
" Uses the original HeightMap value for interpolation, not binary.\n"
|
|
" This allows smooth color transitions within a value range.\n\n"
|
|
"Example:\n"
|
|
" layer.apply_gradient(terrain, (0.3, 0.7),\n"
|
|
" (50, 120, 50), # Dark green at 0.3\n"
|
|
" (100, 200, 100)) # Light green at 0.7"},
|
|
{"apply_ranges", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_ranges, METH_VARARGS,
|
|
"apply_ranges(source, ranges) -> ColorLayer\n\n"
|
|
"Apply multiple color assignments in a single pass.\n\n"
|
|
"Args:\n"
|
|
" source (HeightMap): Source heightmap (must match layer dimensions)\n"
|
|
" ranges (list): List of range specifications. Each entry is:\n"
|
|
" ((min, max), (r, g, b[, a])) - for fixed color\n"
|
|
" ((min, max), ((r1, g1, b1[, a1]), (r2, g2, b2[, a2]))) - for gradient\n\n"
|
|
"Returns:\n"
|
|
" self for method chaining\n\n"
|
|
"Note:\n"
|
|
" Later ranges override earlier ones if overlapping.\n"
|
|
" Cells not matching any range are left unchanged.\n\n"
|
|
"Example:\n"
|
|
" layer.apply_ranges(terrain, [\n"
|
|
" ((0.0, 0.3), (0, 0, 180)), # Fixed blue\n"
|
|
" ((0.3, 0.7), ((50, 120, 50), (100, 200, 100))), # Gradient\n"
|
|
" ((0.7, 1.0), ((100, 100, 100), (255, 255, 255))), # Gradient\n"
|
|
" ])"},
|
|
{NULL}
|
|
};
|
|
|
|
PyGetSetDef PyGridLayerAPI::ColorLayer_getsetters[] = {
|
|
{"z_index", (getter)PyGridLayerAPI::ColorLayer_get_z_index,
|
|
(setter)PyGridLayerAPI::ColorLayer_set_z_index,
|
|
"Layer z-order. Negative values render below entities.", NULL},
|
|
{"visible", (getter)PyGridLayerAPI::ColorLayer_get_visible,
|
|
(setter)PyGridLayerAPI::ColorLayer_set_visible,
|
|
"Whether the layer is rendered.", NULL},
|
|
{"grid_size", (getter)PyGridLayerAPI::ColorLayer_get_grid_size, NULL,
|
|
"Layer dimensions as (width, height) tuple.", NULL},
|
|
{NULL}
|
|
};
|
|
|
|
int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"z_index", "grid_size", NULL};
|
|
int z_index = -1;
|
|
PyObject* grid_size_obj = nullptr;
|
|
int grid_x = 0, grid_y = 0;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast<char**>(kwlist),
|
|
&z_index, &grid_size_obj)) {
|
|
return -1;
|
|
}
|
|
|
|
// Parse grid_size if provided
|
|
if (grid_size_obj && grid_size_obj != Py_None) {
|
|
if (!PyTuple_Check(grid_size_obj) || PyTuple_Size(grid_size_obj) != 2) {
|
|
PyErr_SetString(PyExc_TypeError, "grid_size must be a (width, height) tuple");
|
|
return -1;
|
|
}
|
|
grid_x = PyLong_AsLong(PyTuple_GetItem(grid_size_obj, 0));
|
|
grid_y = PyLong_AsLong(PyTuple_GetItem(grid_size_obj, 1));
|
|
if (PyErr_Occurred()) return -1;
|
|
|
|
// #212 - Validate against GRID_MAX
|
|
if (grid_x > GRID_MAX || grid_y > GRID_MAX) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"ColorLayer dimensions cannot exceed %d (got %dx%d)",
|
|
GRID_MAX, grid_x, grid_y);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// Create the layer (will be attached to grid via add_layer)
|
|
self->data = std::make_shared<ColorLayer>(z_index, grid_x, grid_y, nullptr);
|
|
self->grid.reset();
|
|
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_at(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
int x, y;
|
|
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
|
|
PyErr_SetString(PyExc_IndexError, "Cell coordinates out of bounds");
|
|
return NULL;
|
|
}
|
|
|
|
const sf::Color& color = self->data->at(x, y);
|
|
|
|
// Return as mcrfpy.Color
|
|
auto* color_type = (PyTypeObject*)PyObject_GetAttrString(
|
|
PyImport_ImportModule("mcrfpy"), "Color");
|
|
if (!color_type) return NULL;
|
|
|
|
PyColorObject* color_obj = (PyColorObject*)color_type->tp_alloc(color_type, 0);
|
|
Py_DECREF(color_type);
|
|
if (!color_obj) return NULL;
|
|
|
|
color_obj->data = color;
|
|
return (PyObject*)color_obj;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_set(PyColorLayerObject* self, PyObject* args) {
|
|
PyObject* pos_obj;
|
|
PyObject* color_obj;
|
|
if (!PyArg_ParseTuple(args, "OO", &pos_obj, &color_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
int x, y;
|
|
if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
|
|
PyErr_SetString(PyExc_IndexError, "Cell coordinates out of bounds");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse color
|
|
sf::Color color;
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return NULL;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return NULL;
|
|
|
|
if (PyObject_IsInstance(color_obj, color_type)) {
|
|
color = ((PyColorObject*)color_obj)->data;
|
|
} else if (PyTuple_Check(color_obj)) {
|
|
int r, g, b, a = 255;
|
|
if (!PyArg_ParseTuple(color_obj, "iii|i", &r, &g, &b, &a)) {
|
|
Py_DECREF(color_type);
|
|
return NULL;
|
|
}
|
|
// #213 - Validate color component range
|
|
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 255) {
|
|
Py_DECREF(color_type);
|
|
PyErr_Format(PyExc_ValueError,
|
|
"color components must be in range 0-255, got (%d, %d, %d, %d)",
|
|
r, g, b, a);
|
|
return NULL;
|
|
}
|
|
color = sf::Color(r, g, b, a);
|
|
} else {
|
|
Py_DECREF(color_type);
|
|
PyErr_SetString(PyExc_TypeError, "color must be a Color object or (r, g, b[, a]) tuple");
|
|
return NULL;
|
|
}
|
|
Py_DECREF(color_type);
|
|
|
|
self->data->at(x, y) = color;
|
|
self->data->markDirty(x, y); // Mark only the affected chunk
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_fill(PyColorLayerObject* self, PyObject* args) {
|
|
PyObject* color_obj;
|
|
if (!PyArg_ParseTuple(args, "O", &color_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse color
|
|
sf::Color color;
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return NULL;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return NULL;
|
|
|
|
if (PyObject_IsInstance(color_obj, color_type)) {
|
|
color = ((PyColorObject*)color_obj)->data;
|
|
} else if (PyTuple_Check(color_obj)) {
|
|
int r, g, b, a = 255;
|
|
if (!PyArg_ParseTuple(color_obj, "iii|i", &r, &g, &b, &a)) {
|
|
Py_DECREF(color_type);
|
|
return NULL;
|
|
}
|
|
// #213 - Validate color component range
|
|
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 255) {
|
|
Py_DECREF(color_type);
|
|
PyErr_Format(PyExc_ValueError,
|
|
"color components must be in range 0-255, got (%d, %d, %d, %d)",
|
|
r, g, b, a);
|
|
return NULL;
|
|
}
|
|
color = sf::Color(r, g, b, a);
|
|
} else {
|
|
Py_DECREF(color_type);
|
|
PyErr_SetString(PyExc_TypeError, "color must be a Color object or (r, g, b[, a]) tuple");
|
|
return NULL;
|
|
}
|
|
Py_DECREF(color_type);
|
|
|
|
self->data->fill(color);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_fill_rect(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"pos", "size", "color", NULL};
|
|
PyObject* pos_obj;
|
|
PyObject* size_obj;
|
|
PyObject* color_obj;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOO", const_cast<char**>(kwlist),
|
|
&pos_obj, &size_obj, &color_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse pos
|
|
int x, y;
|
|
if (PyTuple_Check(pos_obj) && PyTuple_Size(pos_obj) == 2) {
|
|
x = PyLong_AsLong(PyTuple_GetItem(pos_obj, 0));
|
|
y = PyLong_AsLong(PyTuple_GetItem(pos_obj, 1));
|
|
if (PyErr_Occurred()) return NULL;
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "pos must be a (x, y) tuple");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse size
|
|
int width, height;
|
|
if (PyTuple_Check(size_obj) && PyTuple_Size(size_obj) == 2) {
|
|
width = PyLong_AsLong(PyTuple_GetItem(size_obj, 0));
|
|
height = PyLong_AsLong(PyTuple_GetItem(size_obj, 1));
|
|
if (PyErr_Occurred()) return NULL;
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "size must be a (width, height) tuple");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse color
|
|
sf::Color color;
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return NULL;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return NULL;
|
|
|
|
if (PyObject_IsInstance(color_obj, color_type)) {
|
|
color = ((PyColorObject*)color_obj)->data;
|
|
} else if (PyTuple_Check(color_obj)) {
|
|
int r, g, b, a = 255;
|
|
if (!PyArg_ParseTuple(color_obj, "iii|i", &r, &g, &b, &a)) {
|
|
Py_DECREF(color_type);
|
|
return NULL;
|
|
}
|
|
// #213 - Validate color component range
|
|
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 255) {
|
|
Py_DECREF(color_type);
|
|
PyErr_Format(PyExc_ValueError,
|
|
"color components must be in range 0-255, got (%d, %d, %d, %d)",
|
|
r, g, b, a);
|
|
return NULL;
|
|
}
|
|
color = sf::Color(r, g, b, a);
|
|
} else {
|
|
Py_DECREF(color_type);
|
|
PyErr_SetString(PyExc_TypeError, "color must be a Color object or (r, g, b[, a]) tuple");
|
|
return NULL;
|
|
}
|
|
Py_DECREF(color_type);
|
|
|
|
self->data->fillRect(x, y, width, height, color);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_draw_fov(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"source", "radius", "fov", "visible", "discovered", "unknown", NULL};
|
|
PyObject* source_obj;
|
|
int radius = -1; // -1 means use grid's default
|
|
PyObject* fov_obj = Py_None;
|
|
PyObject* visible_obj = nullptr;
|
|
PyObject* discovered_obj = nullptr;
|
|
PyObject* unknown_obj = nullptr;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|iOOOO", const_cast<char**>(kwlist),
|
|
&source_obj, &radius, &fov_obj, &visible_obj, &discovered_obj, &unknown_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->grid) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer is not attached to a grid");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse source position
|
|
int source_x, source_y;
|
|
if (PyTuple_Check(source_obj) && PyTuple_Size(source_obj) == 2) {
|
|
source_x = PyLong_AsLong(PyTuple_GetItem(source_obj, 0));
|
|
source_y = PyLong_AsLong(PyTuple_GetItem(source_obj, 1));
|
|
if (PyErr_Occurred()) return NULL;
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a (x, y) tuple");
|
|
return NULL;
|
|
}
|
|
|
|
// Get radius from grid if not specified
|
|
if (radius < 0) {
|
|
radius = self->grid->fov_radius;
|
|
}
|
|
|
|
// Get FOV algorithm
|
|
TCOD_fov_algorithm_t algorithm;
|
|
bool was_none = false;
|
|
if (!PyFOV::from_arg(fov_obj, &algorithm, &was_none)) {
|
|
return NULL;
|
|
}
|
|
if (was_none) {
|
|
algorithm = self->grid->fov_algorithm;
|
|
}
|
|
|
|
// Helper lambda to parse color
|
|
auto parse_color = [](PyObject* obj, sf::Color& out, const sf::Color& default_val, const char* name) -> bool {
|
|
if (!obj || obj == Py_None) {
|
|
out = default_val;
|
|
return true;
|
|
}
|
|
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return false;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return false;
|
|
|
|
if (PyObject_IsInstance(obj, color_type)) {
|
|
out = ((PyColorObject*)obj)->data;
|
|
Py_DECREF(color_type);
|
|
return true;
|
|
} else if (PyTuple_Check(obj)) {
|
|
int r, g, b, a = 255;
|
|
if (!PyArg_ParseTuple(obj, "iii|i", &r, &g, &b, &a)) {
|
|
Py_DECREF(color_type);
|
|
return false;
|
|
}
|
|
out = sf::Color(r, g, b, a);
|
|
Py_DECREF(color_type);
|
|
return true;
|
|
}
|
|
|
|
Py_DECREF(color_type);
|
|
PyErr_Format(PyExc_TypeError, "%s must be a Color object or (r, g, b[, a]) tuple", name);
|
|
return false;
|
|
};
|
|
|
|
// Default colors for FOV visualization
|
|
sf::Color visible_color(255, 255, 200, 64); // Light yellow tint
|
|
sf::Color discovered_color(128, 128, 128, 128); // Gray
|
|
sf::Color unknown_color(0, 0, 0, 255); // Black
|
|
|
|
if (!parse_color(visible_obj, visible_color, visible_color, "visible")) return NULL;
|
|
if (!parse_color(discovered_obj, discovered_color, discovered_color, "discovered")) return NULL;
|
|
if (!parse_color(unknown_obj, unknown_color, unknown_color, "unknown")) return NULL;
|
|
|
|
self->data->drawFOV(source_x, source_y, radius, algorithm, visible_color, discovered_color, unknown_color);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_apply_perspective(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"entity", "visible", "discovered", "unknown", NULL};
|
|
PyObject* entity_obj;
|
|
PyObject* visible_obj = nullptr;
|
|
PyObject* discovered_obj = nullptr;
|
|
PyObject* unknown_obj = nullptr;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast<char**>(kwlist),
|
|
&entity_obj, &visible_obj, &discovered_obj, &unknown_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->grid) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer is not attached to a grid");
|
|
return NULL;
|
|
}
|
|
|
|
// Get the Entity type
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return NULL;
|
|
|
|
auto* entity_type = PyObject_GetAttrString(mcrfpy_module, "Entity");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!entity_type) return NULL;
|
|
|
|
if (!PyObject_IsInstance(entity_obj, entity_type)) {
|
|
Py_DECREF(entity_type);
|
|
PyErr_SetString(PyExc_TypeError, "entity must be an Entity object");
|
|
return NULL;
|
|
}
|
|
Py_DECREF(entity_type);
|
|
|
|
// Get the shared_ptr to the entity
|
|
PyUIEntityObject* py_entity = (PyUIEntityObject*)entity_obj;
|
|
if (!py_entity->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Entity has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Helper lambda to parse color
|
|
auto parse_color = [](PyObject* obj, sf::Color& out, const sf::Color& default_val, const char* name) -> bool {
|
|
if (!obj || obj == Py_None) {
|
|
out = default_val;
|
|
return true;
|
|
}
|
|
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return false;
|
|
|
|
auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!color_type) return false;
|
|
|
|
if (PyObject_IsInstance(obj, color_type)) {
|
|
out = ((PyColorObject*)obj)->data;
|
|
Py_DECREF(color_type);
|
|
return true;
|
|
} else if (PyTuple_Check(obj)) {
|
|
int r, g, b, a = 255;
|
|
if (!PyArg_ParseTuple(obj, "iii|i", &r, &g, &b, &a)) {
|
|
Py_DECREF(color_type);
|
|
return false;
|
|
}
|
|
out = sf::Color(r, g, b, a);
|
|
Py_DECREF(color_type);
|
|
return true;
|
|
}
|
|
|
|
Py_DECREF(color_type);
|
|
PyErr_Format(PyExc_TypeError, "%s must be a Color object or (r, g, b[, a]) tuple", name);
|
|
return false;
|
|
};
|
|
|
|
// Parse colors with defaults
|
|
sf::Color visible_color(255, 255, 200, 64);
|
|
sf::Color discovered_color(100, 100, 100, 128);
|
|
sf::Color unknown_color(0, 0, 0, 255);
|
|
|
|
if (!parse_color(visible_obj, visible_color, visible_color, "visible")) return NULL;
|
|
if (!parse_color(discovered_obj, discovered_color, discovered_color, "discovered")) return NULL;
|
|
if (!parse_color(unknown_obj, unknown_color, unknown_color, "unknown")) return NULL;
|
|
|
|
self->data->applyPerspective(py_entity->data, visible_color, discovered_color, unknown_color);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_update_perspective(PyColorLayerObject* self, PyObject* args) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data->has_perspective) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no perspective binding. Call apply_perspective() first.");
|
|
return NULL;
|
|
}
|
|
|
|
self->data->updatePerspective();
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_clear_perspective(PyColorLayerObject* self, PyObject* args) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
self->data->clearPerspective();
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_apply_hmap_threshold(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"source", "range", "color", NULL};
|
|
PyObject* source_obj;
|
|
PyObject* range_obj;
|
|
PyObject* color_obj;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOO", const_cast<char**>(kwlist),
|
|
&source_obj, &range_obj, &color_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate source is a HeightMap
|
|
PyHeightMapObject* hmap;
|
|
if (!IsHeightMapObject(source_obj, &hmap)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return NULL;
|
|
}
|
|
|
|
// #214 - Check for null heightmap pointer
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap is not initialized");
|
|
return NULL;
|
|
}
|
|
|
|
if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// Parse range
|
|
float range_min, range_max;
|
|
if (!ParseRange(range_obj, &range_min, &range_max, "range")) {
|
|
return NULL;
|
|
}
|
|
|
|
// Parse color
|
|
sf::Color color;
|
|
if (!ParseColorArg(color_obj, color, "color")) {
|
|
return NULL;
|
|
}
|
|
|
|
// Apply threshold
|
|
int width = self->data->grid_x;
|
|
int height = self->data->grid_y;
|
|
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
if (value >= range_min && value <= range_max) {
|
|
self->data->at(x, y) = color;
|
|
}
|
|
}
|
|
}
|
|
|
|
self->data->markDirty();
|
|
|
|
// Return self for chaining
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_apply_gradient(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"source", "range", "color_low", "color_high", NULL};
|
|
PyObject* source_obj;
|
|
PyObject* range_obj;
|
|
PyObject* color_low_obj;
|
|
PyObject* color_high_obj;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOOO", const_cast<char**>(kwlist),
|
|
&source_obj, &range_obj, &color_low_obj, &color_high_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate source is a HeightMap
|
|
PyHeightMapObject* hmap;
|
|
if (!IsHeightMapObject(source_obj, &hmap)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return NULL;
|
|
}
|
|
|
|
// #214 - Check for null heightmap pointer
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap is not initialized");
|
|
return NULL;
|
|
}
|
|
|
|
if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// Parse range
|
|
float range_min, range_max;
|
|
if (!ParseRange(range_obj, &range_min, &range_max, "range")) {
|
|
return NULL;
|
|
}
|
|
|
|
// Parse colors
|
|
sf::Color color_low, color_high;
|
|
if (!ParseColorArg(color_low_obj, color_low, "color_low")) {
|
|
return NULL;
|
|
}
|
|
if (!ParseColorArg(color_high_obj, color_high, "color_high")) {
|
|
return NULL;
|
|
}
|
|
|
|
// Apply gradient
|
|
int width = self->data->grid_x;
|
|
int height = self->data->grid_y;
|
|
float range_span = range_max - range_min;
|
|
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
if (value >= range_min && value <= range_max) {
|
|
// Normalize value within range for interpolation
|
|
float t = (range_span > 0.0f) ? (value - range_min) / range_span : 0.0f;
|
|
self->data->at(x, y) = LerpColor(color_low, color_high, t);
|
|
}
|
|
}
|
|
}
|
|
|
|
self->data->markDirty();
|
|
|
|
// Return self for chaining
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_apply_ranges(PyColorLayerObject* self, PyObject* args) {
|
|
PyObject* source_obj;
|
|
PyObject* ranges_obj;
|
|
|
|
if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate source is a HeightMap
|
|
PyHeightMapObject* hmap;
|
|
if (!IsHeightMapObject(source_obj, &hmap)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return NULL;
|
|
}
|
|
|
|
// #214 - Check for null heightmap pointer
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap is not initialized");
|
|
return NULL;
|
|
}
|
|
|
|
if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// Validate ranges is a list
|
|
if (!PyList_Check(ranges_obj)) {
|
|
PyErr_SetString(PyExc_TypeError, "ranges must be a list");
|
|
return NULL;
|
|
}
|
|
|
|
// Pre-parse all ranges for validation
|
|
// Each range can be:
|
|
// ((min, max), (r, g, b[, a])) - fixed color
|
|
// ((min, max), ((r1, g1, b1[, a1]), (r2, g2, b2[, a2]))) - gradient
|
|
struct ColorRange {
|
|
float min_val, max_val;
|
|
sf::Color color_low;
|
|
sf::Color color_high;
|
|
bool is_gradient;
|
|
};
|
|
std::vector<ColorRange> ranges;
|
|
|
|
Py_ssize_t n_ranges = PyList_Size(ranges_obj);
|
|
for (Py_ssize_t i = 0; i < n_ranges; ++i) {
|
|
PyObject* item = PyList_GetItem(ranges_obj, i);
|
|
|
|
if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"ranges[%zd] must be a ((min, max), color) tuple", i);
|
|
return NULL;
|
|
}
|
|
|
|
PyObject* range_tuple = PyTuple_GetItem(item, 0);
|
|
PyObject* color_spec = PyTuple_GetItem(item, 1);
|
|
|
|
float min_val, max_val;
|
|
char range_name[32];
|
|
snprintf(range_name, sizeof(range_name), "ranges[%zd] range", i);
|
|
if (!ParseRange(range_tuple, &min_val, &max_val, range_name)) {
|
|
return NULL;
|
|
}
|
|
|
|
ColorRange cr;
|
|
cr.min_val = min_val;
|
|
cr.max_val = max_val;
|
|
|
|
// Determine if this is a gradient (tuple of 2 tuples) or fixed color
|
|
// Check if color_spec is a tuple of 2 elements where each element is also a sequence
|
|
bool is_gradient = false;
|
|
if (PyTuple_Check(color_spec) && PyTuple_Size(color_spec) == 2) {
|
|
PyObject* first = PyTuple_GetItem(color_spec, 0);
|
|
PyObject* second = PyTuple_GetItem(color_spec, 1);
|
|
// If both elements are tuples/lists (not ints), it's a gradient
|
|
if ((PyTuple_Check(first) || PyList_Check(first)) &&
|
|
(PyTuple_Check(second) || PyList_Check(second))) {
|
|
is_gradient = true;
|
|
}
|
|
}
|
|
|
|
cr.is_gradient = is_gradient;
|
|
|
|
if (is_gradient) {
|
|
// Parse as gradient: ((r1,g1,b1), (r2,g2,b2))
|
|
PyObject* color_low_obj = PyTuple_GetItem(color_spec, 0);
|
|
PyObject* color_high_obj = PyTuple_GetItem(color_spec, 1);
|
|
|
|
char color_name[48];
|
|
snprintf(color_name, sizeof(color_name), "ranges[%zd] color_low", i);
|
|
if (!ParseColorArg(color_low_obj, cr.color_low, color_name)) {
|
|
return NULL;
|
|
}
|
|
snprintf(color_name, sizeof(color_name), "ranges[%zd] color_high", i);
|
|
if (!ParseColorArg(color_high_obj, cr.color_high, color_name)) {
|
|
return NULL;
|
|
}
|
|
} else {
|
|
// Parse as fixed color
|
|
char color_name[48];
|
|
snprintf(color_name, sizeof(color_name), "ranges[%zd] color", i);
|
|
if (!ParseColorArg(color_spec, cr.color_low, color_name)) {
|
|
return NULL;
|
|
}
|
|
cr.color_high = cr.color_low; // Not used, but set for consistency
|
|
}
|
|
|
|
ranges.push_back(cr);
|
|
}
|
|
|
|
// Apply all ranges in order (later ranges override)
|
|
int width = self->data->grid_x;
|
|
int height = self->data->grid_y;
|
|
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
|
|
// Check ranges in order, last match wins
|
|
for (const auto& cr : ranges) {
|
|
if (value >= cr.min_val && value <= cr.max_val) {
|
|
if (cr.is_gradient) {
|
|
float range_span = cr.max_val - cr.min_val;
|
|
float t = (range_span > 0.0f) ? (value - cr.min_val) / range_span : 0.0f;
|
|
self->data->at(x, y) = LerpColor(cr.color_low, cr.color_high, t);
|
|
} else {
|
|
self->data->at(x, y) = cr.color_low;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self->data->markDirty();
|
|
|
|
// Return self for chaining
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_get_z_index(PyColorLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return PyLong_FromLong(self->data->z_index);
|
|
}
|
|
|
|
int PyGridLayerAPI::ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* value, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return -1;
|
|
}
|
|
long z = PyLong_AsLong(value);
|
|
if (PyErr_Occurred()) return -1;
|
|
self->data->z_index = z;
|
|
// TODO: Trigger re-sort in parent grid
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_get_visible(PyColorLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return PyBool_FromLong(self->data->visible);
|
|
}
|
|
|
|
int PyGridLayerAPI::ColorLayer_set_visible(PyColorLayerObject* self, PyObject* value, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return -1;
|
|
}
|
|
int v = PyObject_IsTrue(value);
|
|
if (v < 0) return -1;
|
|
self->data->visible = v;
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_get_grid_size(PyColorLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y);
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::ColorLayer_repr(PyColorLayerObject* self) {
|
|
std::ostringstream ss;
|
|
if (!self->data) {
|
|
ss << "<ColorLayer (invalid)>";
|
|
} else {
|
|
ss << "<ColorLayer z_index=" << self->data->z_index
|
|
<< " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")"
|
|
<< " visible=" << (self->data->visible ? "True" : "False") << ">";
|
|
}
|
|
return PyUnicode_FromString(ss.str().c_str());
|
|
}
|
|
|
|
// =============================================================================
|
|
// Python API - TileLayer
|
|
// =============================================================================
|
|
|
|
PyMethodDef PyGridLayerAPI::TileLayer_methods[] = {
|
|
{"at", (PyCFunction)PyGridLayerAPI::TileLayer_at, METH_VARARGS | METH_KEYWORDS,
|
|
"at(pos) -> int\nat(x, y) -> int\n\n"
|
|
"Get the tile index at cell position. Returns -1 if no tile.\n\n"
|
|
"Args:\n"
|
|
" pos: Position as (x, y) tuple, list, or Vector\n"
|
|
" x, y: Position as separate integer arguments"},
|
|
{"set", (PyCFunction)PyGridLayerAPI::TileLayer_set, METH_VARARGS,
|
|
"set(pos, index)\n\n"
|
|
"Set the tile index at cell position. Use -1 for no tile.\n\n"
|
|
"Args:\n"
|
|
" pos: Position as (x, y) tuple, list, or Vector\n"
|
|
" index: Tile index (-1 for no tile)"},
|
|
{"fill", (PyCFunction)PyGridLayerAPI::TileLayer_fill, METH_VARARGS,
|
|
"fill(index)\n\nFill the entire layer with the specified tile index."},
|
|
{"fill_rect", (PyCFunction)PyGridLayerAPI::TileLayer_fill_rect, METH_VARARGS | METH_KEYWORDS,
|
|
"fill_rect(pos, size, index)\n\n"
|
|
"Fill a rectangular region with a tile index.\n\n"
|
|
"Args:\n"
|
|
" pos (tuple): Top-left corner as (x, y)\n"
|
|
" size (tuple): Dimensions as (width, height)\n"
|
|
" index (int): Tile index to fill with (-1 for no tile)"},
|
|
{"apply_threshold", (PyCFunction)PyGridLayerAPI::TileLayer_apply_threshold, METH_VARARGS | METH_KEYWORDS,
|
|
"apply_threshold(source, range, tile) -> TileLayer\n\n"
|
|
"Set tile index for cells where HeightMap value is within range.\n\n"
|
|
"Args:\n"
|
|
" source (HeightMap): Source heightmap (must match layer dimensions)\n"
|
|
" range (tuple): Value range as (min, max) inclusive\n"
|
|
" tile (int): Tile index to set for cells in range\n\n"
|
|
"Returns:\n"
|
|
" self for method chaining\n\n"
|
|
"Example:\n"
|
|
" layer.apply_threshold(terrain, (0.0, 0.3), WATER_TILE)"},
|
|
{"apply_ranges", (PyCFunction)PyGridLayerAPI::TileLayer_apply_ranges, METH_VARARGS,
|
|
"apply_ranges(source, ranges) -> TileLayer\n\n"
|
|
"Apply multiple tile assignments in a single pass.\n\n"
|
|
"Args:\n"
|
|
" source (HeightMap): Source heightmap (must match layer dimensions)\n"
|
|
" ranges (list): List of ((min, max), tile_index) tuples\n\n"
|
|
"Returns:\n"
|
|
" self for method chaining\n\n"
|
|
"Note:\n"
|
|
" Later ranges override earlier ones if overlapping.\n"
|
|
" Cells not matching any range are left unchanged.\n\n"
|
|
"Example:\n"
|
|
" layer.apply_ranges(terrain, [\n"
|
|
" ((0.0, 0.2), DEEP_WATER),\n"
|
|
" ((0.2, 0.3), SHALLOW_WATER),\n"
|
|
" ((0.3, 0.7), GRASS),\n"
|
|
" ])"},
|
|
{NULL}
|
|
};
|
|
|
|
PyGetSetDef PyGridLayerAPI::TileLayer_getsetters[] = {
|
|
{"z_index", (getter)PyGridLayerAPI::TileLayer_get_z_index,
|
|
(setter)PyGridLayerAPI::TileLayer_set_z_index,
|
|
"Layer z-order. Negative values render below entities.", NULL},
|
|
{"visible", (getter)PyGridLayerAPI::TileLayer_get_visible,
|
|
(setter)PyGridLayerAPI::TileLayer_set_visible,
|
|
"Whether the layer is rendered.", NULL},
|
|
{"texture", (getter)PyGridLayerAPI::TileLayer_get_texture,
|
|
(setter)PyGridLayerAPI::TileLayer_set_texture,
|
|
"Texture atlas for tile sprites.", NULL},
|
|
{"grid_size", (getter)PyGridLayerAPI::TileLayer_get_grid_size, NULL,
|
|
"Layer dimensions as (width, height) tuple.", NULL},
|
|
{NULL}
|
|
};
|
|
|
|
int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"z_index", "texture", "grid_size", NULL};
|
|
int z_index = -1;
|
|
PyObject* texture_obj = nullptr;
|
|
PyObject* grid_size_obj = nullptr;
|
|
int grid_x = 0, grid_y = 0;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iOO", const_cast<char**>(kwlist),
|
|
&z_index, &texture_obj, &grid_size_obj)) {
|
|
return -1;
|
|
}
|
|
|
|
// Parse texture
|
|
std::shared_ptr<PyTexture> texture;
|
|
if (texture_obj && texture_obj != Py_None) {
|
|
// Check if it's a PyTexture
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return -1;
|
|
|
|
auto* texture_type = PyObject_GetAttrString(mcrfpy_module, "Texture");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!texture_type) return -1;
|
|
|
|
if (PyObject_IsInstance(texture_obj, texture_type)) {
|
|
texture = ((PyTextureObject*)texture_obj)->data;
|
|
} else {
|
|
Py_DECREF(texture_type);
|
|
PyErr_SetString(PyExc_TypeError, "texture must be a Texture object");
|
|
return -1;
|
|
}
|
|
Py_DECREF(texture_type);
|
|
}
|
|
|
|
// Parse grid_size if provided
|
|
if (grid_size_obj && grid_size_obj != Py_None) {
|
|
if (!PyTuple_Check(grid_size_obj) || PyTuple_Size(grid_size_obj) != 2) {
|
|
PyErr_SetString(PyExc_TypeError, "grid_size must be a (width, height) tuple");
|
|
return -1;
|
|
}
|
|
grid_x = PyLong_AsLong(PyTuple_GetItem(grid_size_obj, 0));
|
|
grid_y = PyLong_AsLong(PyTuple_GetItem(grid_size_obj, 1));
|
|
if (PyErr_Occurred()) return -1;
|
|
|
|
// #212 - Validate against GRID_MAX
|
|
if (grid_x > GRID_MAX || grid_y > GRID_MAX) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"TileLayer dimensions cannot exceed %d (got %dx%d)",
|
|
GRID_MAX, grid_x, grid_y);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// Create the layer
|
|
self->data = std::make_shared<TileLayer>(z_index, grid_x, grid_y, nullptr, texture);
|
|
self->grid.reset();
|
|
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_at(PyTileLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
int x, y;
|
|
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
|
|
PyErr_SetString(PyExc_IndexError, "Cell coordinates out of bounds");
|
|
return NULL;
|
|
}
|
|
|
|
return PyLong_FromLong(self->data->at(x, y));
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_set(PyTileLayerObject* self, PyObject* args) {
|
|
PyObject* pos_obj;
|
|
int index;
|
|
if (!PyArg_ParseTuple(args, "Oi", &pos_obj, &index)) {
|
|
return NULL;
|
|
}
|
|
|
|
int x, y;
|
|
if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
|
|
PyErr_SetString(PyExc_IndexError, "Cell coordinates out of bounds");
|
|
return NULL;
|
|
}
|
|
|
|
self->data->at(x, y) = index;
|
|
self->data->markDirty(x, y); // Mark only the affected chunk
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_fill(PyTileLayerObject* self, PyObject* args) {
|
|
int index;
|
|
if (!PyArg_ParseTuple(args, "i", &index)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
self->data->fill(index);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_fill_rect(PyTileLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"pos", "size", "index", NULL};
|
|
PyObject* pos_obj;
|
|
PyObject* size_obj;
|
|
int tile_index;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOi", const_cast<char**>(kwlist),
|
|
&pos_obj, &size_obj, &tile_index)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse pos
|
|
int x, y;
|
|
if (PyTuple_Check(pos_obj) && PyTuple_Size(pos_obj) == 2) {
|
|
x = PyLong_AsLong(PyTuple_GetItem(pos_obj, 0));
|
|
y = PyLong_AsLong(PyTuple_GetItem(pos_obj, 1));
|
|
if (PyErr_Occurred()) return NULL;
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "pos must be a (x, y) tuple");
|
|
return NULL;
|
|
}
|
|
|
|
// Parse size
|
|
int width, height;
|
|
if (PyTuple_Check(size_obj) && PyTuple_Size(size_obj) == 2) {
|
|
width = PyLong_AsLong(PyTuple_GetItem(size_obj, 0));
|
|
height = PyLong_AsLong(PyTuple_GetItem(size_obj, 1));
|
|
if (PyErr_Occurred()) return NULL;
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "size must be a (width, height) tuple");
|
|
return NULL;
|
|
}
|
|
|
|
self->data->fillRect(x, y, width, height, tile_index);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_apply_threshold(PyTileLayerObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"source", "range", "tile", NULL};
|
|
PyObject* source_obj;
|
|
PyObject* range_obj;
|
|
int tile_index;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOi", const_cast<char**>(kwlist),
|
|
&source_obj, &range_obj, &tile_index)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate source is a HeightMap
|
|
PyHeightMapObject* hmap;
|
|
if (!IsHeightMapObject(source_obj, &hmap)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return NULL;
|
|
}
|
|
|
|
// #214 - Check for null heightmap pointer
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap is not initialized");
|
|
return NULL;
|
|
}
|
|
|
|
if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// Parse range
|
|
float range_min, range_max;
|
|
if (!ParseRange(range_obj, &range_min, &range_max, "range")) {
|
|
return NULL;
|
|
}
|
|
|
|
// Apply threshold
|
|
int width = self->data->grid_x;
|
|
int height = self->data->grid_y;
|
|
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
if (value >= range_min && value <= range_max) {
|
|
self->data->at(x, y) = tile_index;
|
|
}
|
|
}
|
|
}
|
|
|
|
self->data->markDirty();
|
|
|
|
// Return self for chaining
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_apply_ranges(PyTileLayerObject* self, PyObject* args) {
|
|
PyObject* source_obj;
|
|
PyObject* ranges_obj;
|
|
|
|
if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate source is a HeightMap
|
|
PyHeightMapObject* hmap;
|
|
if (!IsHeightMapObject(source_obj, &hmap)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return NULL;
|
|
}
|
|
|
|
// #214 - Check for null heightmap pointer
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap is not initialized");
|
|
return NULL;
|
|
}
|
|
|
|
if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// Validate ranges is a list
|
|
if (!PyList_Check(ranges_obj)) {
|
|
PyErr_SetString(PyExc_TypeError, "ranges must be a list");
|
|
return NULL;
|
|
}
|
|
|
|
// Pre-parse all ranges for validation
|
|
struct TileRange {
|
|
float min_val, max_val;
|
|
int tile_index;
|
|
};
|
|
std::vector<TileRange> ranges;
|
|
|
|
Py_ssize_t n_ranges = PyList_Size(ranges_obj);
|
|
for (Py_ssize_t i = 0; i < n_ranges; ++i) {
|
|
PyObject* item = PyList_GetItem(ranges_obj, i);
|
|
|
|
if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"ranges[%zd] must be a ((min, max), tile) tuple", i);
|
|
return NULL;
|
|
}
|
|
|
|
PyObject* range_tuple = PyTuple_GetItem(item, 0);
|
|
PyObject* tile_obj = PyTuple_GetItem(item, 1);
|
|
|
|
float min_val, max_val;
|
|
char range_name[32];
|
|
snprintf(range_name, sizeof(range_name), "ranges[%zd] range", i);
|
|
if (!ParseRange(range_tuple, &min_val, &max_val, range_name)) {
|
|
return NULL;
|
|
}
|
|
|
|
int tile_index = (int)PyLong_AsLong(tile_obj);
|
|
if (PyErr_Occurred()) {
|
|
PyErr_Format(PyExc_TypeError, "ranges[%zd] tile must be an integer", i);
|
|
return NULL;
|
|
}
|
|
|
|
ranges.push_back({min_val, max_val, tile_index});
|
|
}
|
|
|
|
// Apply all ranges in order (later ranges override)
|
|
int width = self->data->grid_x;
|
|
int height = self->data->grid_y;
|
|
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
|
|
// Check ranges in order, last match wins
|
|
for (const auto& range : ranges) {
|
|
if (value >= range.min_val && value <= range.max_val) {
|
|
self->data->at(x, y) = range.tile_index;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self->data->markDirty();
|
|
|
|
// Return self for chaining
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_get_z_index(PyTileLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return PyLong_FromLong(self->data->z_index);
|
|
}
|
|
|
|
int PyGridLayerAPI::TileLayer_set_z_index(PyTileLayerObject* self, PyObject* value, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return -1;
|
|
}
|
|
long z = PyLong_AsLong(value);
|
|
if (PyErr_Occurred()) return -1;
|
|
self->data->z_index = z;
|
|
// TODO: Trigger re-sort in parent grid
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_get_visible(PyTileLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return PyBool_FromLong(self->data->visible);
|
|
}
|
|
|
|
int PyGridLayerAPI::TileLayer_set_visible(PyTileLayerObject* self, PyObject* value, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return -1;
|
|
}
|
|
int v = PyObject_IsTrue(value);
|
|
if (v < 0) return -1;
|
|
self->data->visible = v;
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_get_texture(PyTileLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (!self->data->texture) {
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
auto* texture_type = (PyTypeObject*)PyObject_GetAttrString(
|
|
PyImport_ImportModule("mcrfpy"), "Texture");
|
|
if (!texture_type) return NULL;
|
|
|
|
PyTextureObject* tex_obj = (PyTextureObject*)texture_type->tp_alloc(texture_type, 0);
|
|
Py_DECREF(texture_type);
|
|
if (!tex_obj) return NULL;
|
|
|
|
tex_obj->data = self->data->texture;
|
|
return (PyObject*)tex_obj;
|
|
}
|
|
|
|
int PyGridLayerAPI::TileLayer_set_texture(PyTileLayerObject* self, PyObject* value, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return -1;
|
|
}
|
|
|
|
if (value == Py_None) {
|
|
self->data->texture.reset();
|
|
self->data->markDirty(); // Mark ALL chunks for re-render (texture change affects all)
|
|
return 0;
|
|
}
|
|
|
|
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
|
|
if (!mcrfpy_module) return -1;
|
|
|
|
auto* texture_type = PyObject_GetAttrString(mcrfpy_module, "Texture");
|
|
Py_DECREF(mcrfpy_module);
|
|
if (!texture_type) return -1;
|
|
|
|
if (!PyObject_IsInstance(value, texture_type)) {
|
|
Py_DECREF(texture_type);
|
|
PyErr_SetString(PyExc_TypeError, "texture must be a Texture object or None");
|
|
return -1;
|
|
}
|
|
Py_DECREF(texture_type);
|
|
|
|
self->data->texture = ((PyTextureObject*)value)->data;
|
|
self->data->markDirty(); // Mark ALL chunks for re-render (texture change affects all)
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_get_grid_size(PyTileLayerObject* self, void* closure) {
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y);
|
|
}
|
|
|
|
PyObject* PyGridLayerAPI::TileLayer_repr(PyTileLayerObject* self) {
|
|
std::ostringstream ss;
|
|
if (!self->data) {
|
|
ss << "<TileLayer (invalid)>";
|
|
} else {
|
|
ss << "<TileLayer z_index=" << self->data->z_index
|
|
<< " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")"
|
|
<< " visible=" << (self->data->visible ? "True" : "False")
|
|
<< " texture=" << (self->data->texture ? "set" : "None") << ">";
|
|
}
|
|
return PyUnicode_FromString(ss.str().c_str());
|
|
}
|