LDtk import support

This commit is contained in:
John McCardle 2026-02-07 11:30:32 -05:00
commit de7778b147
24 changed files with 26203 additions and 0 deletions

View file

@ -53,6 +53,7 @@ include_directories(${CMAKE_SOURCE_DIR}/src)
include_directories(${CMAKE_SOURCE_DIR}/src/3d) include_directories(${CMAKE_SOURCE_DIR}/src/3d)
include_directories(${CMAKE_SOURCE_DIR}/src/platform) include_directories(${CMAKE_SOURCE_DIR}/src/platform)
include_directories(${CMAKE_SOURCE_DIR}/src/tiled) include_directories(${CMAKE_SOURCE_DIR}/src/tiled)
include_directories(${CMAKE_SOURCE_DIR}/src/ldtk)
include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML) include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML)
include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include) include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include)

View file

@ -40,6 +40,8 @@
#include "tiled/PyTileSetFile.h" // Tiled tileset loading #include "tiled/PyTileSetFile.h" // Tiled tileset loading
#include "tiled/PyTileMapFile.h" // Tiled tilemap loading #include "tiled/PyTileMapFile.h" // Tiled tilemap loading
#include "tiled/PyWangSet.h" // Wang auto-tile sets #include "tiled/PyWangSet.h" // Wang auto-tile sets
#include "ldtk/PyLdtkProject.h" // LDtk project loading
#include "ldtk/PyAutoRuleSet.h" // LDtk auto-rule sets
#include "McRogueFaceVersion.h" #include "McRogueFaceVersion.h"
#include "GameEngine.h" #include "GameEngine.h"
// ImGui is only available for SFML builds // ImGui is only available for SFML builds
@ -494,6 +496,10 @@ PyObject* PyInit_mcrfpy()
&mcrfpydef::PyTileMapFileType, &mcrfpydef::PyTileMapFileType,
&mcrfpydef::PyWangSetType, &mcrfpydef::PyWangSetType,
/*LDtk project loading*/
&mcrfpydef::PyLdtkProjectType,
&mcrfpydef::PyAutoRuleSetType,
nullptr}; nullptr};
// Types that are used internally but NOT exported to module namespace (#189) // Types that are used internally but NOT exported to module namespace (#189)
@ -575,6 +581,12 @@ PyObject* PyInit_mcrfpy()
mcrfpydef::PyWangSetType.tp_methods = PyWangSet::methods; mcrfpydef::PyWangSetType.tp_methods = PyWangSet::methods;
mcrfpydef::PyWangSetType.tp_getset = PyWangSet::getsetters; mcrfpydef::PyWangSetType.tp_getset = PyWangSet::getsetters;
// LDtk types
mcrfpydef::PyLdtkProjectType.tp_methods = PyLdtkProject::methods;
mcrfpydef::PyLdtkProjectType.tp_getset = PyLdtkProject::getsetters;
mcrfpydef::PyAutoRuleSetType.tp_methods = PyAutoRuleSet::methods;
mcrfpydef::PyAutoRuleSetType.tp_getset = PyAutoRuleSet::getsetters;
// Set up weakref support for all types that need it // Set up weakref support for all types that need it
PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist);
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);

View file

@ -48,6 +48,30 @@ std::shared_ptr<PyTexture> PyTexture::from_rendered(sf::RenderTexture& render_te
return ptex; return ptex;
} }
// Factory method to create texture from an sf::Image (for LDtk flip-baked atlases)
std::shared_ptr<PyTexture> PyTexture::from_image(
const sf::Image& img, int sprite_w, int sprite_h,
const std::string& name)
{
struct MakeSharedEnabler : public PyTexture {
MakeSharedEnabler() : PyTexture() {}
};
auto ptex = std::make_shared<MakeSharedEnabler>();
ptex->texture.loadFromImage(img);
ptex->texture.setSmooth(false);
ptex->source = name;
ptex->sprite_width = sprite_w;
ptex->sprite_height = sprite_h;
auto size = ptex->texture.getSize();
ptex->sheet_width = (sprite_w > 0) ? (size.x / sprite_w) : 0;
ptex->sheet_height = (sprite_h > 0) ? (size.y / sprite_h) : 0;
return ptex;
}
sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s) sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s)
{ {
// Protect against division by zero if texture failed to load // Protect against division by zero if texture failed to load

View file

@ -25,6 +25,11 @@ public:
// #144: Factory method to create texture from rendered content (snapshot) // #144: Factory method to create texture from rendered content (snapshot)
static std::shared_ptr<PyTexture> from_rendered(sf::RenderTexture& render_tex); static std::shared_ptr<PyTexture> from_rendered(sf::RenderTexture& render_tex);
// Factory method to create texture from an sf::Image (for LDtk flip-baked atlases)
static std::shared_ptr<PyTexture> from_image(
const sf::Image& img, int sprite_w, int sprite_h,
const std::string& name = "<generated>");
sf::Sprite sprite(int index, sf::Vector2f pos = sf::Vector2f(0, 0), sf::Vector2f s = sf::Vector2f(1.0, 1.0)); sf::Sprite sprite(int index, sf::Vector2f pos = sf::Vector2f(0, 0), sf::Vector2f s = sf::Vector2f(1.0, 1.0));
int getSpriteCount() const { return sheet_width * sheet_height; } int getSpriteCount() const { return sheet_width * sheet_height; }

View file

@ -0,0 +1,187 @@
#include "AutoRuleResolve.h"
#include <functional>
namespace mcrf {
namespace ldtk {
// ============================================================
// Deterministic hash for pseudo-random decisions
// ============================================================
static uint32_t hashCell(uint32_t seed, int x, int y, int rule_uid) {
// Simple but deterministic hash combining seed, position, and rule
uint32_t h = seed;
h ^= static_cast<uint32_t>(x) * 374761393u;
h ^= static_cast<uint32_t>(y) * 668265263u;
h ^= static_cast<uint32_t>(rule_uid) * 2654435761u;
h = (h ^ (h >> 13)) * 1274126177u;
h = h ^ (h >> 16);
return h;
}
// ============================================================
// IntGrid access with out-of-bounds handling
// ============================================================
static inline int getIntGrid(const int* data, int w, int h, int x, int y, int oob_value) {
if (x < 0 || x >= w || y < 0 || y >= h) {
return (oob_value == -1) ? 0 : oob_value;
}
return data[y * w + x];
}
// ============================================================
// Pattern matching
// ============================================================
static bool matchPattern(const int* intgrid, int w, int h,
int cx, int cy,
const std::vector<int>& pattern, int size,
int oob_value)
{
int half = size / 2;
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
int pattern_val = pattern[py * size + px];
if (pattern_val == 0) continue; // Wildcard
int gx = cx + (px - half);
int gy = cy + (py - half);
int cell_val = getIntGrid(intgrid, w, h, gx, gy, oob_value);
if (pattern_val > 0) {
if (pattern_val >= 1000000) {
// LDtk IntGrid group reference: 1000001 = "any non-empty value"
// (group-based matching; for now treat as "any non-zero")
if (cell_val == 0) return false;
} else {
// Must match this exact value
if (cell_val != pattern_val) return false;
}
} else if (pattern_val < 0) {
if (pattern_val <= -1000000) {
// Negated group reference: -1000001 = "NOT any non-empty" = "must be empty"
if (cell_val != 0) return false;
} else {
// Must NOT be this value (negative = negation)
if (cell_val == -pattern_val) return false;
}
}
}
}
return true;
}
// ============================================================
// Flip pattern generation
// ============================================================
static std::vector<int> flipPatternX(const std::vector<int>& pattern, int size) {
std::vector<int> result(pattern.size());
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
result[y * size + (size - 1 - x)] = pattern[y * size + x];
}
}
return result;
}
static std::vector<int> flipPatternY(const std::vector<int>& pattern, int size) {
std::vector<int> result(pattern.size());
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
result[(size - 1 - y) * size + x] = pattern[y * size + x];
}
}
return result;
}
// ============================================================
// Resolution engine
// ============================================================
std::vector<AutoTileResult> resolveAutoRules(
const int* intgrid_data, int width, int height,
const AutoRuleSet& ruleset, uint32_t seed)
{
int total = width * height;
std::vector<AutoTileResult> result(total, {-1, 0});
for (const auto& group : ruleset.groups) {
if (!group.active) continue;
// Per-group break mask: once a cell is matched by a breakOnMatch rule,
// skip it for subsequent rules in this group
std::vector<bool> break_mask(total, false);
for (const auto& rule : group.rules) {
if (!rule.active) continue;
if (rule.tile_ids.empty()) continue;
if (rule.pattern.empty()) continue;
// Build flip variants: (pattern, flip_bits)
struct Variant {
std::vector<int> pattern;
int flip_bits;
};
std::vector<Variant> variants;
variants.push_back({rule.pattern, 0});
if (rule.flipX) {
std::vector<int> fx = flipPatternX(rule.pattern, rule.size);
variants.push_back({fx, 1});
}
if (rule.flipY) {
std::vector<int> fy = flipPatternY(rule.pattern, rule.size);
variants.push_back({fy, 2});
}
if (rule.flipX && rule.flipY) {
std::vector<int> fxy = flipPatternY(
flipPatternX(rule.pattern, rule.size), rule.size);
variants.push_back({fxy, 3});
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int idx = y * width + x;
if (break_mask[idx]) continue;
// Probability check (deterministic)
if (rule.chance < 1.0f) {
uint32_t h = hashCell(seed, x, y, rule.uid);
float roll = static_cast<float>(h & 0xFFFF) / 65535.0f;
if (roll >= rule.chance) continue;
}
// Try each variant (first match wins)
for (const auto& variant : variants) {
if (matchPattern(intgrid_data, width, height,
x, y, variant.pattern, rule.size,
rule.outOfBoundsValue))
{
// Pick tile (deterministic from seed)
int tile_idx = 0;
if (rule.tile_ids.size() > 1) {
uint32_t h = hashCell(seed, x, y, rule.uid + 1);
tile_idx = h % rule.tile_ids.size();
}
result[idx].tile_id = rule.tile_ids[tile_idx];
result[idx].flip = variant.flip_bits;
if (rule.breakOnMatch) {
break_mask[idx] = true;
}
break; // First matching variant wins
}
}
}
}
}
}
return result;
}
} // namespace ldtk
} // namespace mcrf

View file

@ -0,0 +1,17 @@
#pragma once
#include "LdtkTypes.h"
#include <vector>
#include <cstdint>
namespace mcrf {
namespace ldtk {
// Resolve auto-rules against IntGrid data.
// Returns a flat array of AutoTileResult (one per cell).
// tile_id = -1 means no rule matched that cell.
std::vector<AutoTileResult> resolveAutoRules(
const int* intgrid_data, int width, int height,
const AutoRuleSet& ruleset, uint32_t seed = 0);
} // namespace ldtk
} // namespace mcrf

455
src/ldtk/LdtkParse.cpp Normal file
View file

@ -0,0 +1,455 @@
#include "LdtkParse.h"
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <filesystem>
namespace mcrf {
namespace ldtk {
// ============================================================
// Utility helpers (same pattern as TiledParse.cpp)
// ============================================================
// Null-safe string extraction from JSON.
// nlohmann::json::value() throws type_error if the field exists but is null.
static std::string jsonStr(const nlohmann::json& j, const char* key, const std::string& def = "") {
auto it = j.find(key);
if (it == j.end() || it->is_null()) return def;
return it->get<std::string>();
}
static int jsonInt(const nlohmann::json& j, const char* key, int def = 0) {
auto it = j.find(key);
if (it == j.end() || it->is_null()) return def;
return it->get<int>();
}
static float jsonFloat(const nlohmann::json& j, const char* key, float def = 0.0f) {
auto it = j.find(key);
if (it == j.end() || it->is_null()) return def;
return it->get<float>();
}
static bool jsonBool(const nlohmann::json& j, const char* key, bool def = false) {
auto it = j.find(key);
if (it == j.end() || it->is_null()) return def;
return it->get<bool>();
}
static std::string readFile(const std::string& path) {
std::ifstream f(path);
if (!f.is_open()) {
throw std::runtime_error("Cannot open file: " + path);
}
std::stringstream ss;
ss << f.rdbuf();
return ss.str();
}
static std::string parentDir(const std::string& path) {
std::filesystem::path p(path);
return p.parent_path().string();
}
static std::string resolvePath(const std::string& base_dir, const std::string& relative) {
std::filesystem::path p = std::filesystem::path(base_dir) / relative;
return p.lexically_normal().string();
}
// ============================================================
// Parse tileset definitions -> TileSetData
// ============================================================
static std::shared_ptr<tiled::TileSetData> parseTilesetDef(
const nlohmann::json& def, const std::string& base_dir)
{
auto ts = std::make_shared<tiled::TileSetData>();
ts->name = jsonStr(def, "identifier");
// Resolve image path (relPath can be null for embedded atlases)
std::string rel_path = jsonStr(def, "relPath");
if (!rel_path.empty()) {
ts->image_source = resolvePath(base_dir, rel_path);
}
ts->source_path = ts->image_source;
int grid_size = jsonInt(def, "tileGridSize");
ts->tile_width = grid_size;
ts->tile_height = grid_size;
ts->columns = jsonInt(def, "__cWid");
int rows = jsonInt(def, "__cHei");
ts->tile_count = ts->columns * rows;
ts->margin = jsonInt(def, "padding");
ts->spacing = jsonInt(def, "spacing");
ts->image_width = jsonInt(def, "pxWid");
ts->image_height = jsonInt(def, "pxHei");
// Per-tile custom data
if (def.contains("customData") && def["customData"].is_array()) {
for (const auto& cd : def["customData"]) {
int tile_id = jsonInt(cd, "tileId", -1);
std::string data = jsonStr(cd, "data");
if (tile_id >= 0 && !data.empty()) {
tiled::TileInfo ti;
ti.id = tile_id;
ti.properties["customData"] = tiled::PropertyValue(data);
ts->tile_info[tile_id] = std::move(ti);
}
}
}
// Enum tags as properties
if (def.contains("enumTags") && def["enumTags"].is_array()) {
for (const auto& et : def["enumTags"]) {
std::string enum_id = jsonStr(et, "enumValueId");
if (et.contains("tileIds") && et["tileIds"].is_array()) {
for (const auto& tid_json : et["tileIds"]) {
int tile_id = tid_json.get<int>();
auto& ti = ts->tile_info[tile_id];
ti.id = tile_id;
ti.properties["enum_tag"] = tiled::PropertyValue(enum_id);
}
}
}
}
return ts;
}
// ============================================================
// Convert LDtk tileRectsIds to flat tile index
// LDtk stores tile references as [x, y] pixel rectangles
// ============================================================
static int rectToTileId(const nlohmann::json& rect, int tile_size, int columns) {
if (!rect.is_array() || rect.size() < 2) return -1;
int px_x = rect[0].get<int>();
int px_y = rect[1].get<int>();
if (tile_size <= 0 || columns <= 0) return -1;
int col = px_x / tile_size;
int row = px_y / tile_size;
return row * columns + col;
}
// ============================================================
// Parse auto-rule definitions from layer definitions
// ============================================================
static AutoRule parseAutoRule(const nlohmann::json& rule_json,
int tileset_grid_size, int tileset_columns)
{
AutoRule rule;
rule.uid = jsonInt(rule_json, "uid");
rule.size = jsonInt(rule_json, "size", 3);
rule.active = jsonBool(rule_json, "active", true);
rule.chance = jsonFloat(rule_json, "chance", 1.0f);
rule.breakOnMatch = jsonBool(rule_json, "breakOnMatch", true);
rule.outOfBoundsValue = jsonInt(rule_json, "outOfBoundsValue", -1);
rule.flipX = jsonBool(rule_json, "flipX");
rule.flipY = jsonBool(rule_json, "flipY");
// Pivot offset
rule.pivotX = jsonFloat(rule_json, "pivotX") >= 0.5f ? 1 : 0;
rule.pivotY = jsonFloat(rule_json, "pivotY") >= 0.5f ? 1 : 0;
// Pattern: flat array of size*size integers
if (rule_json.contains("pattern") && rule_json["pattern"].is_array()) {
for (const auto& val : rule_json["pattern"]) {
rule.pattern.push_back(val.get<int>());
}
}
// Tile IDs from tileRectsIds
// Format varies by LDtk version:
// Newer: [[tile_id], [tile_id], ...] - each alternative is [flat_tile_id]
// Older: [[[px_x, px_y]], [[px_x, px_y]], ...] - each alternative is [[x,y] rect]
if (rule_json.contains("tileRectsIds") && rule_json["tileRectsIds"].is_array()) {
for (const auto& alt : rule_json["tileRectsIds"]) {
if (!alt.is_array() || alt.empty()) continue;
// Check the first element to determine format
const auto& first = alt[0];
if (first.is_number_integer()) {
// Flat tile ID format: [tile_id] or [tile_id, ...]
// Take first element as the tile ID for this alternative
rule.tile_ids.push_back(first.get<int>());
} else if (first.is_array()) {
// Pixel rect format: [[px_x, px_y], ...]
int tid = rectToTileId(first, tileset_grid_size, tileset_columns);
if (tid >= 0) {
rule.tile_ids.push_back(tid);
}
}
}
}
// Fallback: legacy tileIds field
if (rule.tile_ids.empty() && rule_json.contains("tileIds") && rule_json["tileIds"].is_array()) {
for (const auto& tid : rule_json["tileIds"]) {
rule.tile_ids.push_back(tid.get<int>());
}
}
return rule;
}
static AutoRuleSet parseAutoRuleSet(const nlohmann::json& layer_def,
const std::unordered_map<int, std::shared_ptr<tiled::TileSetData>>& tileset_by_uid)
{
AutoRuleSet rs;
rs.name = jsonStr(layer_def, "identifier");
rs.gridSize = jsonInt(layer_def, "gridSize");
rs.tilesetDefUid = jsonInt(layer_def, "tilesetDefUid", -1);
// Determine tileset dimensions for tile rect conversion
int ts_grid = 0;
int ts_columns = 0;
auto ts_it = tileset_by_uid.find(rs.tilesetDefUid);
if (ts_it != tileset_by_uid.end() && ts_it->second) {
ts_grid = ts_it->second->tile_width;
ts_columns = ts_it->second->columns;
}
// IntGrid values
if (layer_def.contains("intGridValues") && layer_def["intGridValues"].is_array()) {
for (const auto& igv : layer_def["intGridValues"]) {
IntGridValue v;
v.value = jsonInt(igv, "value");
v.name = jsonStr(igv, "identifier");
rs.intgrid_values.push_back(std::move(v));
}
}
// Auto-rule groups
if (layer_def.contains("autoRuleGroups") && layer_def["autoRuleGroups"].is_array()) {
for (const auto& group_json : layer_def["autoRuleGroups"]) {
AutoRuleGroup group;
group.name = jsonStr(group_json, "name");
group.active = jsonBool(group_json, "active", true);
if (group_json.contains("rules") && group_json["rules"].is_array()) {
for (const auto& rule_json : group_json["rules"]) {
group.rules.push_back(parseAutoRule(rule_json, ts_grid, ts_columns));
}
}
rs.groups.push_back(std::move(group));
}
}
return rs;
}
// ============================================================
// Parse pre-computed auto-layer tiles
// ============================================================
static std::vector<PrecomputedTile> parseAutoLayerTiles(
const nlohmann::json& tiles_json, int tile_size, int columns, int grid_size)
{
std::vector<PrecomputedTile> result;
if (!tiles_json.is_array()) return result;
for (const auto& t : tiles_json) {
PrecomputedTile pt;
// Tile ID from src rect [x, y]
if (t.contains("src") && t["src"].is_array() && t["src"].size() >= 2) {
int px_x = t["src"][0].get<int>();
int px_y = t["src"][1].get<int>();
if (tile_size > 0 && columns > 0) {
int col = px_x / tile_size;
int row = px_y / tile_size;
pt.tile_id = row * columns + col;
} else {
pt.tile_id = 0;
}
} else {
pt.tile_id = jsonInt(t, "t");
}
// Grid position from px array [x, y]
if (t.contains("px") && t["px"].is_array() && t["px"].size() >= 2) {
int px_x = t["px"][0].get<int>();
int px_y = t["px"][1].get<int>();
// Convert pixel position to grid cell
if (grid_size > 0) {
pt.grid_x = px_x / grid_size;
pt.grid_y = px_y / grid_size;
}
}
// Flip flags: f field (0=none, 1=flipX, 2=flipY, 3=both)
pt.flip = jsonInt(t, "f");
pt.alpha = jsonFloat(t, "a", 1.0f);
result.push_back(std::move(pt));
}
return result;
}
// ============================================================
// Parse grid tiles (manual placement)
// ============================================================
static std::vector<PrecomputedTile> parseGridTiles(
const nlohmann::json& tiles_json, int tile_size, int columns, int grid_size)
{
// Same format as auto-layer tiles
return parseAutoLayerTiles(tiles_json, tile_size, columns, grid_size);
}
// ============================================================
// Parse level layer instances
// ============================================================
static LevelLayerData parseLayerInstance(
const nlohmann::json& layer_json,
const std::unordered_map<int, std::shared_ptr<tiled::TileSetData>>& tileset_by_uid)
{
LevelLayerData layer;
layer.name = jsonStr(layer_json, "__identifier");
layer.type = jsonStr(layer_json, "__type");
layer.width = jsonInt(layer_json, "__cWid");
layer.height = jsonInt(layer_json, "__cHei");
layer.gridSize = jsonInt(layer_json, "__gridSize");
layer.tilesetDefUid = jsonInt(layer_json, "__tilesetDefUid", -1);
// Determine tileset parameters for tile rect conversion
int ts_grid = 0;
int ts_columns = 0;
auto ts_it = tileset_by_uid.find(layer.tilesetDefUid);
if (ts_it != tileset_by_uid.end() && ts_it->second) {
ts_grid = ts_it->second->tile_width;
ts_columns = ts_it->second->columns;
}
// IntGrid values (CSV format)
if (layer_json.contains("intGridCsv") && layer_json["intGridCsv"].is_array()) {
for (const auto& val : layer_json["intGridCsv"]) {
layer.intgrid.push_back(val.get<int>());
}
}
// Auto-layer tiles
if (layer_json.contains("autoLayerTiles") && layer_json["autoLayerTiles"].is_array()) {
layer.auto_tiles = parseAutoLayerTiles(
layer_json["autoLayerTiles"], ts_grid, ts_columns, layer.gridSize);
}
// Grid tiles (manual placement)
if (layer_json.contains("gridTiles") && layer_json["gridTiles"].is_array()) {
layer.grid_tiles = parseGridTiles(
layer_json["gridTiles"], ts_grid, ts_columns, layer.gridSize);
}
// Entity instances
if (layer_json.contains("entityInstances") && layer_json["entityInstances"].is_array()) {
layer.entities = layer_json["entityInstances"];
}
return layer;
}
// ============================================================
// Parse levels
// ============================================================
static LevelData parseLevel(
const nlohmann::json& level_json,
const std::unordered_map<int, std::shared_ptr<tiled::TileSetData>>& tileset_by_uid)
{
LevelData level;
level.name = jsonStr(level_json, "identifier");
level.width_px = jsonInt(level_json, "pxWid");
level.height_px = jsonInt(level_json, "pxHei");
level.worldX = jsonInt(level_json, "worldX");
level.worldY = jsonInt(level_json, "worldY");
// Layer instances
if (level_json.contains("layerInstances") && level_json["layerInstances"].is_array()) {
for (const auto& li : level_json["layerInstances"]) {
level.layers.push_back(parseLayerInstance(li, tileset_by_uid));
}
}
return level;
}
// ============================================================
// Public API: load LDtk project
// ============================================================
std::shared_ptr<LdtkProjectData> loadLdtkProject(const std::string& path) {
std::string abs_path = std::filesystem::absolute(path).string();
std::string text = readFile(abs_path);
nlohmann::json j = nlohmann::json::parse(text);
auto proj = std::make_shared<LdtkProjectData>();
proj->source_path = abs_path;
proj->json_version = jsonStr(j, "jsonVersion");
std::string base_dir = parentDir(abs_path);
// Build uid -> tileset map for cross-referencing
std::unordered_map<int, std::shared_ptr<tiled::TileSetData>> tileset_by_uid;
// Parse tileset definitions from defs.tilesets
if (j.contains("defs") && j["defs"].contains("tilesets") && j["defs"]["tilesets"].is_array()) {
for (const auto& ts_def : j["defs"]["tilesets"]) {
int uid = jsonInt(ts_def, "uid", -1);
auto ts = parseTilesetDef(ts_def, base_dir);
proj->tileset_uid_to_index[uid] = static_cast<int>(proj->tilesets.size());
proj->tilesets.push_back(ts);
tileset_by_uid[uid] = ts;
}
}
// Parse layer definitions for auto-rule sets
if (j.contains("defs") && j["defs"].contains("layers") && j["defs"]["layers"].is_array()) {
for (const auto& layer_def : j["defs"]["layers"]) {
std::string layer_type = jsonStr(layer_def, "type");
// Only IntGrid and AutoLayer types have auto-rules
if (layer_type == "IntGrid" || layer_type == "AutoLayer") {
// Only include if there are actual rules
bool has_rules = false;
if (layer_def.contains("autoRuleGroups") && layer_def["autoRuleGroups"].is_array()) {
for (const auto& grp : layer_def["autoRuleGroups"]) {
if (grp.contains("rules") && grp["rules"].is_array() && !grp["rules"].empty()) {
has_rules = true;
break;
}
}
}
bool has_intgrid = layer_def.contains("intGridValues") &&
layer_def["intGridValues"].is_array() &&
!layer_def["intGridValues"].empty();
if (has_rules || has_intgrid) {
int layer_uid = jsonInt(layer_def, "uid", -1);
AutoRuleSet rs = parseAutoRuleSet(layer_def, tileset_by_uid);
proj->ruleset_uid_to_index[layer_uid] = static_cast<int>(proj->rulesets.size());
proj->rulesets.push_back(std::move(rs));
}
}
}
}
// Parse enum definitions
if (j.contains("defs") && j["defs"].contains("enums") && j["defs"]["enums"].is_array()) {
proj->enums = j["defs"]["enums"];
}
// Parse levels
if (j.contains("levels") && j["levels"].is_array()) {
for (const auto& level_json : j["levels"]) {
proj->levels.push_back(parseLevel(level_json, tileset_by_uid));
}
}
return proj;
}
} // namespace ldtk
} // namespace mcrf

12
src/ldtk/LdtkParse.h Normal file
View file

@ -0,0 +1,12 @@
#pragma once
#include "LdtkTypes.h"
#include <Python.h>
namespace mcrf {
namespace ldtk {
// Load an LDtk project from a .ldtk JSON file
std::shared_ptr<LdtkProjectData> loadLdtkProject(const std::string& path);
} // namespace ldtk
} // namespace mcrf

119
src/ldtk/LdtkTypes.h Normal file
View file

@ -0,0 +1,119 @@
#pragma once
#include <string>
#include <vector>
#include <unordered_map>
#include <memory>
#include <cstdint>
#include <nlohmann/json.hpp>
#include "TiledTypes.h" // Reuse TileSetData
namespace mcrf {
namespace ldtk {
// ============================================================
// IntGrid terrain value definition
// ============================================================
struct IntGridValue {
int value; // 1-indexed (0 = empty)
std::string name; // e.g. "grass", "wall"
};
// ============================================================
// Auto-tile rule system
// ============================================================
// Single auto-tile rule
struct AutoRule {
int uid;
int size; // Pattern dimension: 1, 3, 5, or 7
std::vector<int> pattern; // size*size flat array
// 0 = wildcard (any value)
// +N = must match IntGrid value N
// -N = must NOT be IntGrid value N
std::vector<int> tile_ids; // Alternative tiles (random pick)
bool flipX = false;
bool flipY = false;
float chance = 1.0f; // 0.0-1.0 probability
bool breakOnMatch = true;
int outOfBoundsValue = -1; // -1 = treat as empty (0)
bool active = true;
int pivotX = 0, pivotY = 0; // Pivot offset within pattern
};
// Group of rules (evaluated together)
struct AutoRuleGroup {
std::string name;
bool active = true;
std::vector<AutoRule> rules;
};
// Resolution result for a single cell
struct AutoTileResult {
int tile_id;
int flip; // 0=none, 1=flipX, 2=flipY, 3=both
};
// Full rule set for one IntGrid/AutoLayer
struct AutoRuleSet {
std::string name;
int gridSize = 0;
int tilesetDefUid = -1;
std::vector<IntGridValue> intgrid_values;
std::vector<AutoRuleGroup> groups;
// Flip expansion mapping: (tile_id << 2) | flip_bits -> expanded_tile_id
std::unordered_map<uint32_t, int> flip_mapping;
int expanded_tile_count = 0; // Total tiles after flip expansion
};
// ============================================================
// Level and layer data
// ============================================================
// Pre-computed tile from LDtk editor
struct PrecomputedTile {
int tile_id;
int grid_x, grid_y; // Cell coordinates
int flip; // 0=none, 1=flipX, 2=flipY, 3=both
float alpha = 1.0f;
};
// Layer data within a level
struct LevelLayerData {
std::string name;
std::string type; // "IntGrid", "AutoLayer", "Tiles", "Entities"
int width = 0, height = 0; // In cells
int gridSize = 0; // Cell size in pixels
int tilesetDefUid = -1;
std::vector<int> intgrid; // Source IntGrid values
std::vector<PrecomputedTile> auto_tiles; // Pre-computed from editor
std::vector<PrecomputedTile> grid_tiles; // Manual tile placement
nlohmann::json entities; // Entity data as JSON
};
// Level
struct LevelData {
std::string name;
int width_px = 0, height_px = 0; // Pixel dimensions
int worldX = 0, worldY = 0;
std::vector<LevelLayerData> layers;
};
// ============================================================
// Top-level project
// ============================================================
struct LdtkProjectData {
std::string source_path;
std::string json_version;
std::vector<std::shared_ptr<tiled::TileSetData>> tilesets;
std::unordered_map<int, int> tileset_uid_to_index; // uid -> index into tilesets
std::vector<AutoRuleSet> rulesets;
std::unordered_map<int, int> ruleset_uid_to_index; // layer uid -> index into rulesets
std::vector<LevelData> levels;
nlohmann::json enums; // Enum definitions (lightweight JSON exposure)
};
} // namespace ldtk
} // namespace mcrf

307
src/ldtk/PyAutoRuleSet.cpp Normal file
View file

@ -0,0 +1,307 @@
#include "PyAutoRuleSet.h"
#include "AutoRuleResolve.h"
#include "McRFPy_Doc.h"
#include "PyDiscreteMap.h"
#include "GridLayers.h"
#include <cstring>
using namespace mcrf::ldtk;
// ============================================================
// Helper
// ============================================================
const AutoRuleSet& PyAutoRuleSet::getRuleSet(PyAutoRuleSetObject* self) {
return self->parent->rulesets[self->ruleset_index];
}
// ============================================================
// Factory
// ============================================================
PyObject* PyAutoRuleSet::create(std::shared_ptr<LdtkProjectData> parent, int index) {
auto* type = &mcrfpydef::PyAutoRuleSetType;
auto* self = (PyAutoRuleSetObject*)type->tp_alloc(type, 0);
if (!self) return NULL;
new (&self->parent) std::shared_ptr<LdtkProjectData>(parent);
self->ruleset_index = index;
return (PyObject*)self;
}
// ============================================================
// Type lifecycle
// ============================================================
void PyAutoRuleSet::dealloc(PyAutoRuleSetObject* self) {
self->parent.~shared_ptr();
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* PyAutoRuleSet::repr(PyObject* obj) {
auto* self = (PyAutoRuleSetObject*)obj;
const auto& rs = getRuleSet(self);
int total_rules = 0;
for (const auto& g : rs.groups) total_rules += g.rules.size();
return PyUnicode_FromFormat("<AutoRuleSet '%s' values=%d rules=%d groups=%d>",
rs.name.c_str(), (int)rs.intgrid_values.size(),
total_rules, (int)rs.groups.size());
}
// ============================================================
// Properties
// ============================================================
PyObject* PyAutoRuleSet::get_name(PyAutoRuleSetObject* self, void*) {
return PyUnicode_FromString(getRuleSet(self).name.c_str());
}
PyObject* PyAutoRuleSet::get_grid_size(PyAutoRuleSetObject* self, void*) {
return PyLong_FromLong(getRuleSet(self).gridSize);
}
PyObject* PyAutoRuleSet::get_value_count(PyAutoRuleSetObject* self, void*) {
return PyLong_FromLong(getRuleSet(self).intgrid_values.size());
}
PyObject* PyAutoRuleSet::get_values(PyAutoRuleSetObject* self, void*) {
const auto& rs = getRuleSet(self);
PyObject* list = PyList_New(rs.intgrid_values.size());
if (!list) return NULL;
for (size_t i = 0; i < rs.intgrid_values.size(); i++) {
const auto& v = rs.intgrid_values[i];
PyObject* dict = Py_BuildValue("{s:i, s:s}",
"value", v.value,
"name", v.name.c_str());
if (!dict) {
Py_DECREF(list);
return NULL;
}
PyList_SET_ITEM(list, i, dict);
}
return list;
}
PyObject* PyAutoRuleSet::get_rule_count(PyAutoRuleSetObject* self, void*) {
const auto& rs = getRuleSet(self);
int total = 0;
for (const auto& g : rs.groups) total += g.rules.size();
return PyLong_FromLong(total);
}
PyObject* PyAutoRuleSet::get_group_count(PyAutoRuleSetObject* self, void*) {
return PyLong_FromLong(getRuleSet(self).groups.size());
}
// ============================================================
// Methods
// ============================================================
// Convert a name like "grass_terrain" or "Grass Terrain" to "GRASS_TERRAIN"
static std::string toUpperSnakeCase(const std::string& s) {
std::string result;
result.reserve(s.size());
for (size_t i = 0; i < s.size(); i++) {
char c = s[i];
if (c == ' ' || c == '-') {
result += '_';
} else {
result += static_cast<char>(toupper(static_cast<unsigned char>(c)));
}
}
return result;
}
PyObject* PyAutoRuleSet::terrain_enum(PyAutoRuleSetObject* self, PyObject*) {
const auto& rs = getRuleSet(self);
// Import IntEnum from enum module
PyObject* enum_module = PyImport_ImportModule("enum");
if (!enum_module) return NULL;
PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum");
Py_DECREF(enum_module);
if (!int_enum) return NULL;
// Build members dict: NONE=0, then each IntGrid value
PyObject* members = PyDict_New();
if (!members) { Py_DECREF(int_enum); return NULL; }
// NONE = 0 (empty terrain)
PyObject* zero = PyLong_FromLong(0);
PyDict_SetItemString(members, "NONE", zero);
Py_DECREF(zero);
for (const auto& v : rs.intgrid_values) {
std::string key = toUpperSnakeCase(v.name);
if (key.empty()) {
// Fallback name for unnamed values
key = "VALUE_" + std::to_string(v.value);
}
PyObject* val = PyLong_FromLong(v.value);
PyDict_SetItemString(members, key.c_str(), val);
Py_DECREF(val);
}
// Create enum class: IntEnum(rs.name, members)
PyObject* name = PyUnicode_FromString(rs.name.c_str());
PyObject* args = PyTuple_Pack(2, name, members);
Py_DECREF(name);
Py_DECREF(members);
PyObject* enum_class = PyObject_Call(int_enum, args, NULL);
Py_DECREF(args);
Py_DECREF(int_enum);
return enum_class;
}
PyObject* PyAutoRuleSet::resolve(PyAutoRuleSetObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"discrete_map", "seed", nullptr};
PyObject* dmap_obj;
unsigned int seed = 0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|I",
const_cast<char**>(keywords),
&dmap_obj, &seed))
return NULL;
// Validate DiscreteMap
const char* dmap_type_name = Py_TYPE(dmap_obj)->tp_name;
if (!dmap_type_name || strcmp(dmap_type_name, "mcrfpy.DiscreteMap") != 0) {
PyErr_SetString(PyExc_TypeError, "Expected a DiscreteMap object");
return NULL;
}
auto* dmap = (PyDiscreteMapObject*)dmap_obj;
const auto& rs = getRuleSet(self);
// Convert uint8 DiscreteMap to int array for resolution
int w = dmap->w;
int h = dmap->h;
std::vector<int> intgrid(w * h);
for (int i = 0; i < w * h; i++) {
intgrid[i] = static_cast<int>(dmap->values[i]);
}
std::vector<AutoTileResult> results = resolveAutoRules(intgrid.data(), w, h, rs, seed);
// Convert to Python list of tile IDs (last-wins for stacked, simple mode)
PyObject* list = PyList_New(results.size());
if (!list) return NULL;
for (size_t i = 0; i < results.size(); i++) {
PyList_SET_ITEM(list, i, PyLong_FromLong(results[i].tile_id));
}
return list;
}
PyObject* PyAutoRuleSet::apply(PyAutoRuleSetObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"discrete_map", "tile_layer", "seed", nullptr};
PyObject* dmap_obj;
PyObject* tlayer_obj;
unsigned int seed = 0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|I",
const_cast<char**>(keywords),
&dmap_obj, &tlayer_obj, &seed))
return NULL;
// Validate DiscreteMap
const char* dmap_tn = Py_TYPE(dmap_obj)->tp_name;
if (!dmap_tn || strcmp(dmap_tn, "mcrfpy.DiscreteMap") != 0) {
PyErr_SetString(PyExc_TypeError, "First argument must be a DiscreteMap");
return NULL;
}
// Validate TileLayer
const char* tl_tn = Py_TYPE(tlayer_obj)->tp_name;
if (!tl_tn || strcmp(tl_tn, "mcrfpy.TileLayer") != 0) {
PyErr_SetString(PyExc_TypeError, "Second argument must be a TileLayer");
return NULL;
}
auto* dmap = (PyDiscreteMapObject*)dmap_obj;
auto* tlayer = (PyTileLayerObject*)tlayer_obj;
const auto& rs = getRuleSet(self);
// Convert uint8 DiscreteMap to int array
int w = dmap->w;
int h = dmap->h;
std::vector<int> intgrid(w * h);
for (int i = 0; i < w * h; i++) {
intgrid[i] = static_cast<int>(dmap->values[i]);
}
std::vector<AutoTileResult> results = resolveAutoRules(intgrid.data(), w, h, rs, seed);
// Write into TileLayer, applying flip mapping if available
for (int y = 0; y < h && y < tlayer->data->grid_y; y++) {
for (int x = 0; x < w && x < tlayer->data->grid_x; x++) {
int idx = y * w + x;
int tid = results[idx].tile_id;
int flip = results[idx].flip;
if (tid >= 0) {
if (flip != 0 && !rs.flip_mapping.empty()) {
// Look up expanded tile ID for flipped variant
uint32_t key = (static_cast<uint32_t>(tid) << 2) | (flip & 3);
auto it = rs.flip_mapping.find(key);
if (it != rs.flip_mapping.end()) {
tid = it->second;
}
// If no mapping found, use original tile (no flip)
}
tlayer->data->at(x, y) = tid;
}
}
}
tlayer->data->markDirty();
Py_RETURN_NONE;
}
// ============================================================
// Method/GetSet tables
// ============================================================
PyMethodDef PyAutoRuleSet::methods[] = {
{"terrain_enum", (PyCFunction)PyAutoRuleSet::terrain_enum, METH_NOARGS,
MCRF_METHOD(AutoRuleSet, terrain_enum,
MCRF_SIG("()", "IntEnum"),
MCRF_DESC("Generate a Python IntEnum from this rule set's IntGrid values."),
MCRF_RETURNS("IntEnum class with NONE=0 and one member per IntGrid value (UPPER_SNAKE_CASE).")
)},
{"resolve", (PyCFunction)PyAutoRuleSet::resolve, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(AutoRuleSet, resolve,
MCRF_SIG("(discrete_map: DiscreteMap, seed: int = 0)", "list[int]"),
MCRF_DESC("Resolve IntGrid data to tile indices using LDtk auto-rules."),
MCRF_ARGS_START
MCRF_ARG("discrete_map", "A DiscreteMap with IntGrid values matching this rule set")
MCRF_ARG("seed", "Random seed for deterministic tile selection and probability (default: 0)")
MCRF_RETURNS("List of tile IDs (one per cell). -1 means no matching rule.")
)},
{"apply", (PyCFunction)PyAutoRuleSet::apply, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(AutoRuleSet, apply,
MCRF_SIG("(discrete_map: DiscreteMap, tile_layer: TileLayer, seed: int = 0)", "None"),
MCRF_DESC("Resolve auto-rules and write tile indices directly into a TileLayer."),
MCRF_ARGS_START
MCRF_ARG("discrete_map", "A DiscreteMap with IntGrid values")
MCRF_ARG("tile_layer", "Target TileLayer to write resolved tiles into")
MCRF_ARG("seed", "Random seed for deterministic results (default: 0)")
)},
{NULL}
};
PyGetSetDef PyAutoRuleSet::getsetters[] = {
{"name", (getter)PyAutoRuleSet::get_name, NULL,
MCRF_PROPERTY(name, "Rule set name / layer identifier (str, read-only)."), NULL},
{"grid_size", (getter)PyAutoRuleSet::get_grid_size, NULL,
MCRF_PROPERTY(grid_size, "Cell size in pixels (int, read-only)."), NULL},
{"value_count", (getter)PyAutoRuleSet::get_value_count, NULL,
MCRF_PROPERTY(value_count, "Number of IntGrid terrain values (int, read-only)."), NULL},
{"values", (getter)PyAutoRuleSet::get_values, NULL,
MCRF_PROPERTY(values, "List of IntGrid value dicts with value and name (read-only)."), NULL},
{"rule_count", (getter)PyAutoRuleSet::get_rule_count, NULL,
MCRF_PROPERTY(rule_count, "Total number of rules across all groups (int, read-only)."), NULL},
{"group_count", (getter)PyAutoRuleSet::get_group_count, NULL,
MCRF_PROPERTY(group_count, "Number of rule groups (int, read-only)."), NULL},
{NULL}
};

75
src/ldtk/PyAutoRuleSet.h Normal file
View file

@ -0,0 +1,75 @@
#pragma once
#include "Python.h"
#include "LdtkTypes.h"
#include <memory>
// Python object structure
// Holds shared_ptr to parent project (keeps it alive) + index into rulesets
typedef struct PyAutoRuleSetObject {
PyObject_HEAD
std::shared_ptr<mcrf::ldtk::LdtkProjectData> parent;
int ruleset_index;
} PyAutoRuleSetObject;
// Python binding class
class PyAutoRuleSet {
public:
// Factory: create from parent project + index
static PyObject* create(std::shared_ptr<mcrf::ldtk::LdtkProjectData> parent, int index);
static void dealloc(PyAutoRuleSetObject* self);
static PyObject* repr(PyObject* obj);
// Read-only properties
static PyObject* get_name(PyAutoRuleSetObject* self, void* closure);
static PyObject* get_grid_size(PyAutoRuleSetObject* self, void* closure);
static PyObject* get_value_count(PyAutoRuleSetObject* self, void* closure);
static PyObject* get_values(PyAutoRuleSetObject* self, void* closure);
static PyObject* get_rule_count(PyAutoRuleSetObject* self, void* closure);
static PyObject* get_group_count(PyAutoRuleSetObject* self, void* closure);
// Methods
static PyObject* terrain_enum(PyAutoRuleSetObject* self, PyObject* args);
static PyObject* resolve(PyAutoRuleSetObject* self, PyObject* args, PyObject* kwds);
static PyObject* apply(PyAutoRuleSetObject* self, PyObject* args, PyObject* kwds);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
private:
static const mcrf::ldtk::AutoRuleSet& getRuleSet(PyAutoRuleSetObject* self);
};
// Type definition in mcrfpydef namespace
namespace mcrfpydef {
inline PyTypeObject PyAutoRuleSetType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.AutoRuleSet",
.tp_basicsize = sizeof(PyAutoRuleSetObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyAutoRuleSet::dealloc,
.tp_repr = PyAutoRuleSet::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"AutoRuleSet - LDtk auto-tile rule set for pattern-based terrain rendering.\n\n"
"AutoRuleSets are obtained from LdtkProject.ruleset().\n"
"They map IntGrid terrain values to sprite tiles using LDtk's\n"
"pattern-matching auto-rule system.\n\n"
"Properties:\n"
" name (str, read-only): Rule set name (layer identifier).\n"
" grid_size (int, read-only): Cell size in pixels.\n"
" value_count (int, read-only): Number of IntGrid values.\n"
" values (list, read-only): List of value dicts.\n"
" rule_count (int, read-only): Total rules across all groups.\n"
" group_count (int, read-only): Number of rule groups.\n\n"
"Example:\n"
" rs = project.ruleset('Walls')\n"
" Terrain = rs.terrain_enum()\n"
" rs.apply(discrete_map, tile_layer, seed=42)\n"
),
.tp_methods = nullptr, // Set before PyType_Ready
.tp_getset = nullptr, // Set before PyType_Ready
};
} // namespace mcrfpydef

315
src/ldtk/PyLdtkProject.cpp Normal file
View file

@ -0,0 +1,315 @@
#include "PyLdtkProject.h"
#include "LdtkParse.h"
#include "PyAutoRuleSet.h"
#include "PyTileSetFile.h" // For PyTileSetFileObject
#include "McRFPy_Doc.h"
#include "TiledParse.h" // For jsonToPython()
#include "PyTexture.h"
#include <cstring>
using namespace mcrf::ldtk;
// ============================================================
// Type lifecycle
// ============================================================
PyObject* PyLdtkProject::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
auto* self = (PyLdtkProjectObject*)type->tp_alloc(type, 0);
if (self) {
new (&self->data) std::shared_ptr<LdtkProjectData>();
}
return (PyObject*)self;
}
int PyLdtkProject::init(PyLdtkProjectObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"path", nullptr};
const char* path = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(keywords), &path))
return -1;
try {
self->data = loadLdtkProject(path);
} catch (const std::exception& e) {
PyErr_Format(PyExc_IOError, "Failed to load LDtk project: %s", e.what());
return -1;
}
return 0;
}
void PyLdtkProject::dealloc(PyLdtkProjectObject* self) {
self->data.~shared_ptr();
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* PyLdtkProject::repr(PyObject* obj) {
auto* self = (PyLdtkProjectObject*)obj;
if (!self->data) {
return PyUnicode_FromString("<LdtkProject (uninitialized)>");
}
return PyUnicode_FromFormat("<LdtkProject v%s tilesets=%d rulesets=%d levels=%d>",
self->data->json_version.c_str(),
(int)self->data->tilesets.size(),
(int)self->data->rulesets.size(),
(int)self->data->levels.size());
}
// ============================================================
// Properties (all read-only)
// ============================================================
PyObject* PyLdtkProject::get_version(PyLdtkProjectObject* self, void*) {
return PyUnicode_FromString(self->data->json_version.c_str());
}
PyObject* PyLdtkProject::get_tileset_names(PyLdtkProjectObject* self, void*) {
PyObject* list = PyList_New(self->data->tilesets.size());
if (!list) return NULL;
for (size_t i = 0; i < self->data->tilesets.size(); i++) {
PyList_SET_ITEM(list, i, PyUnicode_FromString(self->data->tilesets[i]->name.c_str()));
}
return list;
}
PyObject* PyLdtkProject::get_ruleset_names(PyLdtkProjectObject* self, void*) {
PyObject* list = PyList_New(self->data->rulesets.size());
if (!list) return NULL;
for (size_t i = 0; i < self->data->rulesets.size(); i++) {
PyList_SET_ITEM(list, i, PyUnicode_FromString(self->data->rulesets[i].name.c_str()));
}
return list;
}
PyObject* PyLdtkProject::get_level_names(PyLdtkProjectObject* self, void*) {
PyObject* list = PyList_New(self->data->levels.size());
if (!list) return NULL;
for (size_t i = 0; i < self->data->levels.size(); i++) {
PyList_SET_ITEM(list, i, PyUnicode_FromString(self->data->levels[i].name.c_str()));
}
return list;
}
PyObject* PyLdtkProject::get_enums(PyLdtkProjectObject* self, void*) {
return mcrf::tiled::jsonToPython(self->data->enums);
}
// ============================================================
// Methods
// ============================================================
PyObject* PyLdtkProject::tileset(PyLdtkProjectObject* self, PyObject* args) {
const char* name = nullptr;
if (!PyArg_ParseTuple(args, "s", &name))
return NULL;
for (size_t i = 0; i < self->data->tilesets.size(); i++) {
if (self->data->tilesets[i]->name == name) {
// Return a TileSetFile-compatible object
// We create a PyTileSetFileObject wrapping our existing TileSetData
PyObject* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL;
PyObject* tsf_type = PyObject_GetAttrString(mcrfpy_module, "TileSetFile");
Py_DECREF(mcrfpy_module);
if (!tsf_type) return NULL;
// Allocate without calling init (we set data directly)
PyObject* tsf_obj = ((PyTypeObject*)tsf_type)->tp_alloc((PyTypeObject*)tsf_type, 0);
Py_DECREF(tsf_type);
if (!tsf_obj) return NULL;
// Construct the shared_ptr in place using the proper type
auto* tsf = (PyTileSetFileObject*)tsf_obj;
new (&tsf->data) std::shared_ptr<mcrf::tiled::TileSetData>(self->data->tilesets[i]);
return tsf_obj;
}
}
PyErr_Format(PyExc_KeyError, "No tileset named '%s'", name);
return NULL;
}
PyObject* PyLdtkProject::ruleset(PyLdtkProjectObject* self, PyObject* args) {
const char* name = nullptr;
if (!PyArg_ParseTuple(args, "s", &name))
return NULL;
for (size_t i = 0; i < self->data->rulesets.size(); i++) {
if (self->data->rulesets[i].name == name) {
return PyAutoRuleSet::create(self->data, static_cast<int>(i));
}
}
PyErr_Format(PyExc_KeyError, "No ruleset named '%s'", name);
return NULL;
}
PyObject* PyLdtkProject::level(PyLdtkProjectObject* self, PyObject* args) {
const char* name = nullptr;
if (!PyArg_ParseTuple(args, "s", &name))
return NULL;
for (size_t i = 0; i < self->data->levels.size(); i++) {
const auto& lvl = self->data->levels[i];
if (lvl.name == name) {
// Build Python dict representing the level
PyObject* dict = PyDict_New();
if (!dict) return NULL;
PyObject* py_name = PyUnicode_FromString(lvl.name.c_str());
PyDict_SetItemString(dict, "name", py_name);
Py_DECREF(py_name);
PyObject* py_w = PyLong_FromLong(lvl.width_px);
PyDict_SetItemString(dict, "width_px", py_w);
Py_DECREF(py_w);
PyObject* py_h = PyLong_FromLong(lvl.height_px);
PyDict_SetItemString(dict, "height_px", py_h);
Py_DECREF(py_h);
PyObject* py_wx = PyLong_FromLong(lvl.worldX);
PyDict_SetItemString(dict, "world_x", py_wx);
Py_DECREF(py_wx);
PyObject* py_wy = PyLong_FromLong(lvl.worldY);
PyDict_SetItemString(dict, "world_y", py_wy);
Py_DECREF(py_wy);
// Build layers list
PyObject* layers_list = PyList_New(lvl.layers.size());
if (!layers_list) { Py_DECREF(dict); return NULL; }
for (size_t j = 0; j < lvl.layers.size(); j++) {
const auto& layer = lvl.layers[j];
PyObject* layer_dict = PyDict_New();
if (!layer_dict) { Py_DECREF(layers_list); Py_DECREF(dict); return NULL; }
PyObject* ln = PyUnicode_FromString(layer.name.c_str());
PyDict_SetItemString(layer_dict, "name", ln);
Py_DECREF(ln);
PyObject* lt = PyUnicode_FromString(layer.type.c_str());
PyDict_SetItemString(layer_dict, "type", lt);
Py_DECREF(lt);
PyObject* lw = PyLong_FromLong(layer.width);
PyDict_SetItemString(layer_dict, "width", lw);
Py_DECREF(lw);
PyObject* lh = PyLong_FromLong(layer.height);
PyDict_SetItemString(layer_dict, "height", lh);
Py_DECREF(lh);
// IntGrid data
if (!layer.intgrid.empty()) {
PyObject* ig = PyList_New(layer.intgrid.size());
for (size_t k = 0; k < layer.intgrid.size(); k++) {
PyList_SET_ITEM(ig, k, PyLong_FromLong(layer.intgrid[k]));
}
PyDict_SetItemString(layer_dict, "intgrid", ig);
Py_DECREF(ig);
} else {
PyObject* ig = PyList_New(0);
PyDict_SetItemString(layer_dict, "intgrid", ig);
Py_DECREF(ig);
}
// Auto tiles
auto tile_to_dict = [](const PrecomputedTile& t) -> PyObject* {
return Py_BuildValue("{s:i, s:i, s:i, s:i, s:f}",
"tile_id", t.tile_id,
"x", t.grid_x,
"y", t.grid_y,
"flip", t.flip,
"alpha", (double)t.alpha);
};
PyObject* auto_list = PyList_New(layer.auto_tiles.size());
for (size_t k = 0; k < layer.auto_tiles.size(); k++) {
PyList_SET_ITEM(auto_list, k, tile_to_dict(layer.auto_tiles[k]));
}
PyDict_SetItemString(layer_dict, "auto_tiles", auto_list);
Py_DECREF(auto_list);
PyObject* grid_list = PyList_New(layer.grid_tiles.size());
for (size_t k = 0; k < layer.grid_tiles.size(); k++) {
PyList_SET_ITEM(grid_list, k, tile_to_dict(layer.grid_tiles[k]));
}
PyDict_SetItemString(layer_dict, "grid_tiles", grid_list);
Py_DECREF(grid_list);
// Entity instances
if (!layer.entities.is_null()) {
PyObject* ents = mcrf::tiled::jsonToPython(layer.entities);
PyDict_SetItemString(layer_dict, "entities", ents);
Py_DECREF(ents);
} else {
PyObject* ents = PyList_New(0);
PyDict_SetItemString(layer_dict, "entities", ents);
Py_DECREF(ents);
}
PyList_SET_ITEM(layers_list, j, layer_dict);
}
PyDict_SetItemString(dict, "layers", layers_list);
Py_DECREF(layers_list);
return dict;
}
}
PyErr_Format(PyExc_KeyError, "No level named '%s'", name);
return NULL;
}
// ============================================================
// Method/GetSet tables
// ============================================================
PyMethodDef PyLdtkProject::methods[] = {
{"tileset", (PyCFunction)PyLdtkProject::tileset, METH_VARARGS,
MCRF_METHOD(LdtkProject, tileset,
MCRF_SIG("(name: str)", "TileSetFile"),
MCRF_DESC("Get a tileset by name."),
MCRF_ARGS_START
MCRF_ARG("name", "Tileset identifier from the LDtk project")
MCRF_RETURNS("A TileSetFile object for texture creation and tile metadata.")
MCRF_RAISES("KeyError", "If no tileset with the given name exists")
)},
{"ruleset", (PyCFunction)PyLdtkProject::ruleset, METH_VARARGS,
MCRF_METHOD(LdtkProject, ruleset,
MCRF_SIG("(name: str)", "AutoRuleSet"),
MCRF_DESC("Get an auto-rule set by layer name."),
MCRF_ARGS_START
MCRF_ARG("name", "Layer identifier from the LDtk project")
MCRF_RETURNS("An AutoRuleSet for resolving IntGrid data to sprite tiles.")
MCRF_RAISES("KeyError", "If no ruleset with the given name exists")
)},
{"level", (PyCFunction)PyLdtkProject::level, METH_VARARGS,
MCRF_METHOD(LdtkProject, level,
MCRF_SIG("(name: str)", "dict"),
MCRF_DESC("Get level data by name."),
MCRF_ARGS_START
MCRF_ARG("name", "Level identifier from the LDtk project")
MCRF_RETURNS("Dict with name, dimensions, world position, and layer data.")
MCRF_RAISES("KeyError", "If no level with the given name exists")
)},
{NULL}
};
PyGetSetDef PyLdtkProject::getsetters[] = {
{"version", (getter)PyLdtkProject::get_version, NULL,
MCRF_PROPERTY(version, "LDtk JSON format version string (str, read-only)."), NULL},
{"tileset_names", (getter)PyLdtkProject::get_tileset_names, NULL,
MCRF_PROPERTY(tileset_names, "List of tileset identifier names (list[str], read-only)."), NULL},
{"ruleset_names", (getter)PyLdtkProject::get_ruleset_names, NULL,
MCRF_PROPERTY(ruleset_names, "List of rule set / layer names (list[str], read-only)."), NULL},
{"level_names", (getter)PyLdtkProject::get_level_names, NULL,
MCRF_PROPERTY(level_names, "List of level identifier names (list[str], read-only)."), NULL},
{"enums", (getter)PyLdtkProject::get_enums, NULL,
MCRF_PROPERTY(enums, "Enum definitions from the project as a list of dicts (read-only)."), NULL},
{NULL}
};

72
src/ldtk/PyLdtkProject.h Normal file
View file

@ -0,0 +1,72 @@
#pragma once
#include "Python.h"
#include "LdtkTypes.h"
#include <memory>
// Python object structure
typedef struct PyLdtkProjectObject {
PyObject_HEAD
std::shared_ptr<mcrf::ldtk::LdtkProjectData> data;
} PyLdtkProjectObject;
// Python binding class
class PyLdtkProject {
public:
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyLdtkProjectObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyLdtkProjectObject* self);
static PyObject* repr(PyObject* obj);
// Read-only properties
static PyObject* get_version(PyLdtkProjectObject* self, void* closure);
static PyObject* get_tileset_names(PyLdtkProjectObject* self, void* closure);
static PyObject* get_ruleset_names(PyLdtkProjectObject* self, void* closure);
static PyObject* get_level_names(PyLdtkProjectObject* self, void* closure);
static PyObject* get_enums(PyLdtkProjectObject* self, void* closure);
// Methods
static PyObject* tileset(PyLdtkProjectObject* self, PyObject* args);
static PyObject* ruleset(PyLdtkProjectObject* self, PyObject* args);
static PyObject* level(PyLdtkProjectObject* self, PyObject* args);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
// Type definition in mcrfpydef namespace
namespace mcrfpydef {
inline PyTypeObject PyLdtkProjectType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.LdtkProject",
.tp_basicsize = sizeof(PyLdtkProjectObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyLdtkProject::dealloc,
.tp_repr = PyLdtkProject::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"LdtkProject(path: str)\n\n"
"Load an LDtk project file (.ldtk).\n\n"
"Parses the project and provides access to tilesets, auto-rule sets,\n"
"levels, and enum definitions.\n\n"
"Args:\n"
" path: Path to the .ldtk project file.\n\n"
"Properties:\n"
" version (str, read-only): LDtk JSON format version.\n"
" tileset_names (list[str], read-only): Names of all tilesets.\n"
" ruleset_names (list[str], read-only): Names of all rule sets.\n"
" level_names (list[str], read-only): Names of all levels.\n"
" enums (dict, read-only): Enum definitions from the project.\n\n"
"Example:\n"
" proj = mcrfpy.LdtkProject('dungeon.ldtk')\n"
" ts = proj.tileset('Dungeon_Tiles')\n"
" rs = proj.ruleset('Walls')\n"
" level = proj.level('Level_0')\n"
),
.tp_methods = nullptr, // Set before PyType_Ready
.tp_getset = nullptr, // Set before PyType_Ready
.tp_init = (initproc)PyLdtkProject::init,
.tp_new = PyLdtkProject::pynew,
};
} // namespace mcrfpydef

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,438 @@
# ldtk_demo.py - Visual demo of LDtk import system
# Shows prebuilt level content and procedural generation via auto-rules
# Uses the official LDtk TopDown example with real sprite art
#
# Usage:
# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/ldtk_demo.py
# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/ldtk_demo.py
import mcrfpy
from mcrfpy import automation
import sys
# -- Asset Paths -------------------------------------------------------
LDTK_PATH = "../tests/demo/ldtk/Typical_TopDown_example.ldtk"
# -- Load Project ------------------------------------------------------
print("Loading LDtk TopDown example...")
proj = mcrfpy.LdtkProject(LDTK_PATH)
ts = proj.tileset("TopDown_by_deepnight")
texture = ts.to_texture()
rs = proj.ruleset("Collisions")
Terrain = rs.terrain_enum()
print(f" Project: v{proj.version}")
print(f" Tileset: {ts.name} ({ts.tile_count} tiles, {ts.tile_width}x{ts.tile_height}px)")
print(f" Ruleset: {rs.name} ({rs.rule_count} rules, {rs.group_count} groups)")
print(f" Terrain values: {[t.name for t in Terrain]}")
print(f" Levels: {proj.level_names}")
# -- Helper: Info Panel -------------------------------------------------
def make_info_panel(scene, lines, x=560, y=60, w=220, h=None):
"""Create a semi-transparent info panel with text lines."""
if h is None:
h = len(lines) * 22 + 20
panel = mcrfpy.Frame(pos=(x, y), size=(w, h),
fill_color=mcrfpy.Color(20, 20, 30, 220),
outline_color=mcrfpy.Color(80, 80, 120),
outline=1.5)
scene.children.append(panel)
for i, text in enumerate(lines):
cap = mcrfpy.Caption(text=text, pos=(10, 10 + i * 22))
cap.fill_color = mcrfpy.Color(200, 200, 220)
panel.children.append(cap)
return panel
# ======================================================================
# SCREEN 1: Prebuilt Level Content (all 3 levels)
# ======================================================================
print("\nSetting up Screen 1: Prebuilt Levels...")
scene1 = mcrfpy.Scene("ldtk_prebuilt")
bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene1.children.append(bg1)
title1 = mcrfpy.Caption(text="LDtk Prebuilt Levels (auto-layer tiles from editor)",
pos=(20, 10))
title1.fill_color = mcrfpy.Color(255, 255, 255)
scene1.children.append(title1)
# Load all 3 levels side by side
level_grids = []
level_x_offset = 20
for li, lname in enumerate(proj.level_names):
level = proj.level(lname)
# Find the Collisions layer (has the auto_tiles)
for layer_info in level["layers"]:
if layer_info["name"] == "Collisions" and layer_info.get("auto_tiles"):
lw, lh = layer_info["width"], layer_info["height"]
auto_tiles = layer_info["auto_tiles"]
# Label
label = mcrfpy.Caption(
text=f"{lname} ({lw}x{lh})",
pos=(level_x_offset, 38))
label.fill_color = mcrfpy.Color(180, 220, 255)
scene1.children.append(label)
# Create layer with prebuilt tiles
prebuilt_layer = mcrfpy.TileLayer(
name=f"prebuilt_{lname}", texture=texture,
grid_size=(lw, lh))
prebuilt_layer.fill(-1)
for tile in auto_tiles:
x, y = tile["x"], tile["y"]
if 0 <= x < lw and 0 <= y < lh:
prebuilt_layer.set((x, y), tile["tile_id"])
# Determine display size (scale to fit)
max_w = 310 if li < 2 else 310
max_h = 300
scale = min(max_w / (lw * ts.tile_width),
max_h / (lh * ts.tile_height))
disp_w = int(lw * ts.tile_width * scale)
disp_h = int(lh * ts.tile_height * scale)
grid = mcrfpy.Grid(
grid_size=(lw, lh),
pos=(level_x_offset, 60),
size=(disp_w, disp_h),
layers=[prebuilt_layer])
grid.fill_color = mcrfpy.Color(30, 30, 50)
grid.center = (lw * ts.tile_width // 2,
lh * ts.tile_height // 2)
scene1.children.append(grid)
level_grids.append((lname, lw, lh, len(auto_tiles)))
level_x_offset += disp_w + 20
break
# Info panel
info_lines = [
"LDtk Prebuilt Content",
"",
f"Project: TopDown example",
f"Version: {proj.version}",
f"Tileset: {ts.name}",
f" {ts.tile_count} tiles, {ts.tile_width}x{ts.tile_height}px",
"",
"Levels loaded:",
]
for lname, lw, lh, natiles in level_grids:
info_lines.append(f" {lname}: {lw}x{lh}")
info_lines.append(f" auto_tiles: {natiles}")
make_info_panel(scene1, info_lines, x=20, y=400, w=400)
nav1 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav1.fill_color = mcrfpy.Color(120, 120, 150)
scene1.children.append(nav1)
# ======================================================================
# SCREEN 2: Procedural Generation via Auto-Rules
# ======================================================================
print("\nSetting up Screen 2: Procedural Generation...")
scene2 = mcrfpy.Scene("ldtk_procgen")
bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene2.children.append(bg2)
title2 = mcrfpy.Caption(
text="LDtk Procedural Generation (auto-rules at runtime)",
pos=(20, 10))
title2.fill_color = mcrfpy.Color(255, 255, 255)
scene2.children.append(title2)
# Generate a procedural dungeon using the LDtk Collisions rules
PW, PH = 32, 24
proc_dm = mcrfpy.DiscreteMap((PW, PH), fill=0)
# Fill everything with walls first
for y in range(PH):
for x in range(PW):
proc_dm.set(x, y, int(Terrain.WALLS))
# Carve out rooms as floor (value 0 = empty/floor in this tileset)
rooms = [
(2, 2, 10, 6), # Room 1: top-left
(16, 2, 13, 6), # Room 2: top-right
(2, 12, 8, 10), # Room 3: bottom-left
(14, 11, 14, 11), # Room 4: bottom-right
(10, 8, 6, 5), # Room 5: center
]
for rx, ry, rw, rh in rooms:
for y in range(ry, min(ry + rh, PH)):
for x in range(rx, min(rx + rw, PW)):
proc_dm.set(x, y, 0) # 0 = floor/empty
# Connect rooms with corridors
corridors = [
# Horizontal corridors
(11, 4, 16, 6), # Room 1 -> Room 2
(9, 12, 14, 14), # Room 3 -> Room 4
# Vertical corridors
(5, 7, 7, 12), # Room 1 -> Room 3
(20, 7, 22, 11), # Room 2 -> Room 4
# Center connections
(10, 9, 14, 11), # Center -> Room 4
]
for cx1, cy1, cx2, cy2 in corridors:
for y in range(cy1, min(cy2 + 1, PH)):
for x in range(cx1, min(cx2 + 1, PW)):
proc_dm.set(x, y, 0)
# Apply auto-rules
proc_layer = mcrfpy.TileLayer(
name="procgen", texture=texture, grid_size=(PW, PH))
proc_layer.fill(-1)
rs.apply(proc_dm, proc_layer, seed=42)
# Stats
wall_count = sum(1 for y in range(PH) for x in range(PW)
if proc_dm.get(x, y) == int(Terrain.WALLS))
floor_count = PW * PH - wall_count
resolved = rs.resolve(proc_dm, seed=42)
matched = sum(1 for t in resolved if t >= 0)
unmatched = sum(1 for t in resolved if t == -1)
print(f" Dungeon: {PW}x{PH}")
print(f" Walls: {wall_count}, Floors: {floor_count}")
print(f" Resolved: {matched} matched, {unmatched} unmatched")
# Display grid
disp_w2 = min(520, PW * ts.tile_width)
disp_h2 = min(520, PH * ts.tile_height)
scale2 = min(520 / (PW * ts.tile_width), 520 / (PH * ts.tile_height))
disp_w2 = int(PW * ts.tile_width * scale2)
disp_h2 = int(PH * ts.tile_height * scale2)
grid2 = mcrfpy.Grid(grid_size=(PW, PH),
pos=(20, 60), size=(disp_w2, disp_h2),
layers=[proc_layer])
grid2.fill_color = mcrfpy.Color(30, 30, 50)
grid2.center = (PW * ts.tile_width // 2, PH * ts.tile_height // 2)
scene2.children.append(grid2)
# Info panel
make_info_panel(scene2, [
"Procedural Dungeon",
"",
f"Grid: {PW}x{PH}",
f"Seed: 42",
"",
"Terrain counts:",
f" WALLS: {wall_count}",
f" FLOOR: {floor_count}",
"",
"Resolution:",
f" Matched: {matched}/{PW*PH}",
f" Unmatched: {unmatched}",
"",
f"Rules: {rs.rule_count} total",
f"Groups: {rs.group_count}",
"",
"5 rooms + corridors",
"carved from solid walls",
], x=disp_w2 + 40, y=60, w=260)
nav2 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav2.fill_color = mcrfpy.Color(120, 120, 150)
scene2.children.append(nav2)
# ======================================================================
# SCREEN 3: Side-by-Side Comparison
# ======================================================================
print("\nSetting up Screen 3: Comparison...")
scene3 = mcrfpy.Scene("ldtk_compare")
bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(10, 10, 15))
scene3.children.append(bg3)
title3 = mcrfpy.Caption(
text="LDtk: Prebuilt vs Re-resolved (same tileset, same rules)",
pos=(20, 10))
title3.fill_color = mcrfpy.Color(255, 255, 255)
scene3.children.append(title3)
# Use World_Level_1 (16x16, square, compact)
cmp_level = proj.level("World_Level_1")
cmp_layer = None
for layer in cmp_level["layers"]:
if layer["name"] == "Collisions":
cmp_layer = layer
break
cw, ch = cmp_layer["width"], cmp_layer["height"]
cmp_auto_tiles = cmp_layer["auto_tiles"]
cmp_intgrid = cmp_layer["intgrid"]
# Left: Prebuilt tiles from editor
left_label = mcrfpy.Caption(text="Prebuilt (from LDtk editor)", pos=(20, 38))
left_label.fill_color = mcrfpy.Color(180, 220, 255)
scene3.children.append(left_label)
left_layer = mcrfpy.TileLayer(
name="cmp_prebuilt", texture=texture, grid_size=(cw, ch))
left_layer.fill(-1)
for tile in cmp_auto_tiles:
x, y = tile["x"], tile["y"]
if 0 <= x < cw and 0 <= y < ch:
left_layer.set((x, y), tile["tile_id"])
grid_left = mcrfpy.Grid(grid_size=(cw, ch),
pos=(20, 60), size=(350, 350),
layers=[left_layer])
grid_left.fill_color = mcrfpy.Color(30, 30, 50)
grid_left.center = (cw * ts.tile_width // 2, ch * ts.tile_height // 2)
scene3.children.append(grid_left)
# Right: Re-resolved using our engine
right_label = mcrfpy.Caption(
text="Re-resolved (our engine, same IntGrid)", pos=(400, 38))
right_label.fill_color = mcrfpy.Color(180, 255, 220)
scene3.children.append(right_label)
cmp_dm = mcrfpy.DiscreteMap((cw, ch))
for y in range(ch):
for x in range(cw):
cmp_dm.set(x, y, cmp_intgrid[y * cw + x])
right_layer = mcrfpy.TileLayer(
name="cmp_resolved", texture=texture, grid_size=(cw, ch))
right_layer.fill(-1)
rs.apply(cmp_dm, right_layer, seed=42)
grid_right = mcrfpy.Grid(grid_size=(cw, ch),
pos=(400, 60), size=(350, 350),
layers=[right_layer])
grid_right.fill_color = mcrfpy.Color(30, 30, 50)
grid_right.center = (cw * ts.tile_width // 2, ch * ts.tile_height // 2)
scene3.children.append(grid_right)
# Tile comparison stats
cmp_matched = 0
cmp_mismatched = 0
for y in range(ch):
for x in range(cw):
pre = left_layer.at(x, y)
res = right_layer.at(x, y)
if pre == res:
cmp_matched += 1
else:
cmp_mismatched += 1
cmp_total = cw * ch
cmp_pct = (cmp_matched / cmp_total * 100) if cmp_total > 0 else 0
print(f" Comparison Level_1: {cmp_matched}/{cmp_total} match ({cmp_pct:.0f}%)")
# Bottom: Another procgen with different seed
bot_label = mcrfpy.Caption(
text="Procgen (new layout, seed=999)", pos=(20, 430))
bot_label.fill_color = mcrfpy.Color(255, 220, 180)
scene3.children.append(bot_label)
BW, BH = 16, 16
bot_dm = mcrfpy.DiscreteMap((BW, BH), fill=int(Terrain.WALLS))
# Diamond room shape
for y in range(BH):
for x in range(BW):
cx_d = abs(x - BW // 2)
cy_d = abs(y - BH // 2)
if cx_d + cy_d < 6:
bot_dm.set(x, y, 0) # floor
# Add some internal walls (pillars)
for px, py in [(6, 6), (9, 6), (6, 9), (9, 9)]:
bot_dm.set(px, py, int(Terrain.WALLS))
bot_layer = mcrfpy.TileLayer(
name="bot_procgen", texture=texture, grid_size=(BW, BH))
bot_layer.fill(-1)
rs.apply(bot_dm, bot_layer, seed=999)
grid_bot = mcrfpy.Grid(grid_size=(BW, BH),
pos=(20, 460), size=(250, 250),
layers=[bot_layer])
grid_bot.fill_color = mcrfpy.Color(30, 30, 50)
grid_bot.center = (BW * ts.tile_width // 2, BH * ts.tile_height // 2)
scene3.children.append(grid_bot)
# Info
make_info_panel(scene3, [
"Tile-by-Tile Comparison",
f"Level: World_Level_1 ({cw}x{ch})",
"",
f" Matches: {cmp_matched}/{cmp_total}",
f" Mismatches: {cmp_mismatched}/{cmp_total}",
f" Match rate: {cmp_pct:.0f}%",
"",
"Prebuilt has stacked tiles",
"(shadows, outlines, etc.)",
"Our engine picks last match",
"per cell (single layer).",
"",
"Bottom: diamond room +",
"4 pillars, seed=999",
], x=300, y=440, w=340)
nav3 = mcrfpy.Caption(
text="[1] Prebuilt [2] Procgen [3] Compare [ESC] Quit",
pos=(20, 740))
nav3.fill_color = mcrfpy.Color(120, 120, 150)
scene3.children.append(nav3)
# ======================================================================
# Navigation & Screenshots
# ======================================================================
scenes = [scene1, scene2, scene3]
scene_names = ["prebuilt", "procgen", "compare"]
def on_key(key, action):
if action != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.NUM_1:
mcrfpy.current_scene = scene1
elif key == mcrfpy.Key.NUM_2:
mcrfpy.current_scene = scene2
elif key == mcrfpy.Key.NUM_3:
mcrfpy.current_scene = scene3
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
for s in scenes:
s.on_key = on_key
# Detect headless mode
is_headless = False
try:
win = mcrfpy.Window.get()
is_headless = "headless" in str(win).lower()
except:
is_headless = True
if is_headless:
for i, (sc, name) in enumerate(zip(scenes, scene_names)):
mcrfpy.current_scene = sc
for _ in range(3):
mcrfpy.step(0.016)
fname = f"ldtk_demo_{name}.png"
automation.screenshot(fname)
print(f" Screenshot: {fname}")
print("\nAll screenshots captured. Done!")
sys.exit(0)
else:
mcrfpy.current_scene = scene1
print("\nLDtk Demo ready!")
print("Press [1] [2] [3] to switch screens, [ESC] to quit")

View file

@ -0,0 +1,183 @@
"""Unit tests for LDtk auto-rule apply (resolve + write to TileLayer)."""
import mcrfpy
import sys
def test_apply_basic():
"""Test applying rules to a TileLayer."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# Create DiscreteMap
dm = mcrfpy.DiscreteMap((5, 5), fill=0)
for y in range(5):
for x in range(5):
if x == 0 or x == 4 or y == 0 or y == 4:
dm.set(x, y, 1)
elif x == 2 and y == 2:
dm.set(x, y, 3)
else:
dm.set(x, y, 2)
# Create TileLayer and apply
layer = mcrfpy.TileLayer(name="terrain", texture=texture, grid_size=(5, 5))
rs.apply(dm, layer, seed=0)
# Verify some tiles were written
wall_tile = layer.at(0, 0)
assert wall_tile == 0, f"Expected wall tile 0 at (0,0), got {wall_tile}"
floor_tile = layer.at(1, 1)
assert floor_tile >= 0, f"Expected floor tile at (1,1), got {floor_tile}"
# Empty cells (water, value=3) should still be -1 (no rule matches water)
water_tile = layer.at(2, 2)
assert water_tile == -1, f"Expected -1 at water (2,2), got {water_tile}"
print(f" applied: wall={wall_tile}, floor={floor_tile}, water={water_tile}")
def test_apply_preserves_unmatched():
"""Test that unmatched cells retain their original value."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# Pre-fill layer with a sentinel value
layer = mcrfpy.TileLayer(name="test", texture=texture, grid_size=(3, 3))
layer.fill(99)
# Create empty map - no rules will match
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
rs.apply(dm, layer, seed=0)
# All cells should still be 99 (no rules matched)
for y in range(3):
for x in range(3):
val = layer.at(x, y)
assert val == 99, f"Expected 99 at ({x},{y}), got {val}"
print(" unmatched cells preserved: OK")
def test_apply_type_errors():
"""Test that apply raises TypeError for wrong argument types."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Wrong first argument type
try:
rs.apply("not_a_dmap", None, seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
# Wrong second argument type
dm = mcrfpy.DiscreteMap((3, 3))
try:
rs.apply(dm, "not_a_layer", seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
print(" type errors raised correctly: OK")
def test_apply_clipping():
"""Test that apply clips to the smaller of map/layer dimensions."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
# DiscreteMap larger than TileLayer
dm = mcrfpy.DiscreteMap((10, 10), fill=1)
layer = mcrfpy.TileLayer(name="small", texture=texture, grid_size=(3, 3))
layer.fill(-1)
rs.apply(dm, layer, seed=0)
# Layer should have tiles written only within its bounds
for y in range(3):
for x in range(3):
val = layer.at(x, y)
assert val >= 0, f"Expected tile at ({x},{y}), got {val}"
print(" clipping (large map, small layer): OK")
# DiscreteMap smaller than TileLayer
dm2 = mcrfpy.DiscreteMap((2, 2), fill=1)
layer2 = mcrfpy.TileLayer(name="big", texture=texture, grid_size=(5, 5))
layer2.fill(88)
rs.apply(dm2, layer2, seed=0)
# Only (0,0)-(1,1) should be overwritten
for y in range(2):
for x in range(2):
val = layer2.at(x, y)
assert val >= 0, f"Expected tile at ({x},{y}), got {val}"
# (3,3) should still be the fill value
assert layer2.at(3, 3) == 88, f"Expected 88 at (3,3), got {layer2.at(3, 3)}"
print(" clipping (small map, large layer): OK")
def test_resolve_type_error():
"""Test that resolve raises TypeError for wrong argument."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
try:
rs.resolve("not_a_dmap", seed=0)
assert False, "Should have raised TypeError"
except TypeError:
pass
print(" resolve TypeError: OK")
def test_precomputed_tiles():
"""Test loading pre-computed auto-layer tiles from a level."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
ts = proj.tileset("Test_Tileset")
texture = ts.to_texture()
level = proj.level("Level_0")
layer_info = level["layers"][0]
# Create TileLayer and write pre-computed tiles
layer = mcrfpy.TileLayer(name="precomp", texture=texture, grid_size=(5, 5))
layer.fill(-1)
for tile in layer_info["auto_tiles"]:
x, y = tile["x"], tile["y"]
if 0 <= x < 5 and 0 <= y < 5:
layer.set((x, y), tile["tile_id"])
# Verify some tiles were written
assert layer.at(0, 0) == 0, f"Expected tile 0 at (0,0), got {layer.at(0, 0)}"
print(f" precomputed tiles loaded: first = {layer.at(0, 0)}")
# Run tests
tests = [
test_apply_basic,
test_apply_preserves_unmatched,
test_apply_type_errors,
test_apply_clipping,
test_resolve_type_error,
test_precomputed_tiles,
]
passed = 0
failed = 0
print("=== LDtk Apply Tests ===")
for test in tests:
name = test.__name__
try:
print(f"[TEST] {name}...")
test()
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)

View file

@ -0,0 +1,222 @@
"""Unit tests for LDtk project parsing."""
import mcrfpy
import sys
import os
def test_load_project():
"""Test basic project loading."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
assert proj is not None, "Failed to create LdtkProject"
print(f" repr: {repr(proj)}")
return proj
def test_version(proj):
"""Test version property."""
assert proj.version == "1.5.3", f"Expected version '1.5.3', got '{proj.version}'"
print(f" version: {proj.version}")
def test_tileset_names(proj):
"""Test tileset enumeration."""
names = proj.tileset_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 tileset, got {len(names)}"
assert names[0] == "Test_Tileset", f"Expected 'Test_Tileset', got '{names[0]}'"
print(f" tileset_names: {names}")
def test_ruleset_names(proj):
"""Test ruleset enumeration."""
names = proj.ruleset_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 ruleset, got {len(names)}"
assert names[0] == "Terrain", f"Expected 'Terrain', got '{names[0]}'"
print(f" ruleset_names: {names}")
def test_level_names(proj):
"""Test level enumeration."""
names = proj.level_names
assert isinstance(names, list), f"Expected list, got {type(names)}"
assert len(names) == 1, f"Expected 1 level, got {len(names)}"
assert names[0] == "Level_0", f"Expected 'Level_0', got '{names[0]}'"
print(f" level_names: {names}")
def test_enums(proj):
"""Test enum access."""
enums = proj.enums
assert isinstance(enums, list), f"Expected list, got {type(enums)}"
assert len(enums) == 1, f"Expected 1 enum, got {len(enums)}"
assert enums[0]["identifier"] == "TileType"
print(f" enums: {len(enums)} enum(s), first = {enums[0]['identifier']}")
def test_tileset_access(proj):
"""Test tileset retrieval."""
ts = proj.tileset("Test_Tileset")
assert ts is not None, "Failed to get tileset"
print(f" tileset: {repr(ts)}")
assert ts.name == "Test_Tileset", f"Expected 'Test_Tileset', got '{ts.name}'"
assert ts.tile_width == 16, f"Expected tile_width 16, got {ts.tile_width}"
assert ts.tile_height == 16, f"Expected tile_height 16, got {ts.tile_height}"
assert ts.columns == 4, f"Expected columns 4, got {ts.columns}"
assert ts.tile_count == 16, f"Expected tile_count 16, got {ts.tile_count}"
def test_tileset_not_found(proj):
"""Test KeyError for missing tileset."""
try:
proj.tileset("Nonexistent")
assert False, "Should have raised KeyError"
except KeyError:
pass
print(" KeyError raised for missing tileset: OK")
def test_ruleset_access(proj):
"""Test ruleset retrieval."""
rs = proj.ruleset("Terrain")
assert rs is not None, "Failed to get ruleset"
print(f" ruleset: {repr(rs)}")
assert rs.name == "Terrain", f"Expected 'Terrain', got '{rs.name}'"
assert rs.grid_size == 16, f"Expected grid_size 16, got {rs.grid_size}"
assert rs.value_count == 3, f"Expected 3 values, got {rs.value_count}"
assert rs.group_count == 2, f"Expected 2 groups, got {rs.group_count}"
assert rs.rule_count == 3, f"Expected 3 rules, got {rs.rule_count}"
def test_ruleset_values(proj):
"""Test IntGrid value definitions."""
rs = proj.ruleset("Terrain")
values = rs.values
assert len(values) == 3, f"Expected 3 values, got {len(values)}"
assert values[0]["value"] == 1
assert values[0]["name"] == "wall"
assert values[1]["value"] == 2
assert values[1]["name"] == "floor"
assert values[2]["value"] == 3
assert values[2]["name"] == "water"
print(f" values: {values}")
def test_terrain_enum(proj):
"""Test terrain_enum() generation."""
rs = proj.ruleset("Terrain")
Terrain = rs.terrain_enum()
assert Terrain is not None, "Failed to create terrain enum"
assert Terrain.NONE == 0, f"Expected NONE=0, got {Terrain.NONE}"
assert Terrain.WALL == 1, f"Expected WALL=1, got {Terrain.WALL}"
assert Terrain.FLOOR == 2, f"Expected FLOOR=2, got {Terrain.FLOOR}"
assert Terrain.WATER == 3, f"Expected WATER=3, got {Terrain.WATER}"
print(f" terrain enum: {list(Terrain)}")
def test_level_access(proj):
"""Test level data retrieval."""
level = proj.level("Level_0")
assert isinstance(level, dict), f"Expected dict, got {type(level)}"
assert level["name"] == "Level_0"
assert level["width_px"] == 80
assert level["height_px"] == 80
assert level["world_x"] == 0
assert level["world_y"] == 0
print(f" level: {level['name']} ({level['width_px']}x{level['height_px']}px)")
def test_level_layers(proj):
"""Test level layer data."""
level = proj.level("Level_0")
layers = level["layers"]
assert len(layers) == 1, f"Expected 1 layer, got {len(layers)}"
layer = layers[0]
assert layer["name"] == "Terrain"
assert layer["type"] == "IntGrid"
assert layer["width"] == 5
assert layer["height"] == 5
print(f" layer: {layer['name']} ({layer['type']}) {layer['width']}x{layer['height']}")
def test_level_intgrid(proj):
"""Test IntGrid CSV data."""
level = proj.level("Level_0")
layer = level["layers"][0]
intgrid = layer["intgrid"]
assert len(intgrid) == 25, f"Expected 25 cells, got {len(intgrid)}"
# Check corners are walls (1)
assert intgrid[0] == 1, f"Expected wall at (0,0), got {intgrid[0]}"
assert intgrid[4] == 1, f"Expected wall at (4,0), got {intgrid[4]}"
# Check center is water (3)
assert intgrid[12] == 3, f"Expected water at (2,2), got {intgrid[12]}"
# Check floor tiles (2)
assert intgrid[6] == 2, f"Expected floor at (1,1), got {intgrid[6]}"
print(f" intgrid: {intgrid[:5]}... ({len(intgrid)} cells)")
def test_level_auto_tiles(proj):
"""Test pre-computed auto-layer tiles."""
level = proj.level("Level_0")
layer = level["layers"][0]
auto_tiles = layer["auto_tiles"]
assert len(auto_tiles) > 0, f"Expected auto tiles, got {len(auto_tiles)}"
# Check first tile structure
t = auto_tiles[0]
assert "tile_id" in t, f"Missing tile_id in auto tile: {t}"
assert "x" in t, f"Missing x in auto tile: {t}"
assert "y" in t, f"Missing y in auto tile: {t}"
assert "flip" in t, f"Missing flip in auto tile: {t}"
print(f" auto_tiles: {len(auto_tiles)} tiles, first = {auto_tiles[0]}")
def test_level_not_found(proj):
"""Test KeyError for missing level."""
try:
proj.level("Nonexistent")
assert False, "Should have raised KeyError"
except KeyError:
pass
print(" KeyError raised for missing level: OK")
def test_load_nonexistent():
"""Test IOError for missing file."""
try:
mcrfpy.LdtkProject("nonexistent.ldtk")
assert False, "Should have raised IOError"
except IOError:
pass
print(" IOError raised for missing file: OK")
# Run all tests
tests = [
("load_project", None),
("version", None),
("tileset_names", None),
("ruleset_names", None),
("level_names", None),
("enums", None),
("tileset_access", None),
("tileset_not_found", None),
("ruleset_access", None),
("ruleset_values", None),
("terrain_enum", None),
("level_access", None),
("level_layers", None),
("level_intgrid", None),
("level_auto_tiles", None),
("level_not_found", None),
("load_nonexistent", None),
]
passed = 0
failed = 0
proj = None
# First test returns the project
print("=== LDtk Parse Tests ===")
for name, func in tests:
try:
test_fn = globals()[f"test_{name}"]
print(f"[TEST] {name}...")
if name == "load_project":
proj = test_fn()
elif name in ("load_nonexistent",):
test_fn()
else:
test_fn(proj)
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)

View file

@ -0,0 +1,146 @@
"""Unit tests for LDtk auto-rule resolution."""
import mcrfpy
import sys
def test_basic_resolve():
"""Test resolving a simple IntGrid against auto-rules."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Create a DiscreteMap matching the test fixture
dm = mcrfpy.DiscreteMap((5, 5), fill=0)
# Fill with the same pattern as test_project.ldtk Level_0:
# 1 1 1 1 1
# 1 2 2 2 1
# 1 2 3 2 1
# 1 2 2 2 1
# 1 1 1 1 1
for y in range(5):
for x in range(5):
if x == 0 or x == 4 or y == 0 or y == 4:
dm.set(x, y, 1) # wall
elif x == 2 and y == 2:
dm.set(x, y, 3) # water
else:
dm.set(x, y, 2) # floor
tiles = rs.resolve(dm, seed=0)
assert isinstance(tiles, list), f"Expected list, got {type(tiles)}"
assert len(tiles) == 25, f"Expected 25 tiles, got {len(tiles)}"
print(f" resolved: {tiles}")
# Wall cells (value=1) should have tile_id 0 (from rule 51 matching pattern center=1)
assert tiles[0] >= 0, f"Expected wall tile at (0,0), got {tiles[0]}"
# Floor cells (value=2) should match floor rule (rule 61, tile_id 2 or 3)
assert tiles[6] >= 0, f"Expected floor tile at (1,1), got {tiles[6]}"
print(" wall and floor cells matched rules: OK")
def test_resolve_with_seed():
"""Test that different seeds produce deterministic but different results for multi-tile rules."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
dm = mcrfpy.DiscreteMap((5, 5), fill=2) # All floor
tiles_a = rs.resolve(dm, seed=0)
tiles_b = rs.resolve(dm, seed=0)
tiles_c = rs.resolve(dm, seed=42)
# Same seed = same result
assert tiles_a == tiles_b, "Same seed should produce same result"
print(" deterministic with same seed: OK")
# Different seed may produce different tile picks (floor rule has 2 alternatives)
# Not guaranteed to differ for all cells, but we test determinism
tiles_d = rs.resolve(dm, seed=42)
assert tiles_c == tiles_d, "Same seed should produce same result"
print(" deterministic with different seed: OK")
def test_resolve_empty():
"""Test resolving an all-empty grid (value 0 = empty)."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
tiles = rs.resolve(dm, seed=0)
assert len(tiles) == 9, f"Expected 9 tiles, got {len(tiles)}"
# All empty - no rules should match (rules match value 1 or 2)
for i, t in enumerate(tiles):
assert t == -1, f"Expected -1 at index {i}, got {t}"
print(" empty grid: all tiles -1: OK")
def test_pattern_negation():
"""Test that negative pattern values work (must NOT match)."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Rule 52 has pattern: [0, -1, 0, 0, 1, 0, 0, 0, 0]
# Center must be 1 (wall), top neighbor must NOT be 1
# Create a 3x3 grid with wall center and non-wall top
dm = mcrfpy.DiscreteMap((3, 3), fill=0)
dm.set(1, 1, 1) # center = wall
dm.set(1, 0, 2) # top = floor (not wall)
tiles = rs.resolve(dm, seed=0)
# The center cell should match rule 52 (wall with non-wall top)
# Rule 52 gives tile_id 1 (from tileRectsIds [16,0] = column 1, row 0 = tile 1)
center = tiles[4] # (1,1) = index 4 in 3x3
print(f" negation pattern: center tile = {center}")
# It should match either rule 51 (generic wall) or rule 52 (wall with non-wall top)
assert center >= 0, f"Expected match at center, got {center}"
print(" pattern negation test: OK")
def test_resolve_dimensions():
"""Test resolve works with different grid dimensions."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
for w, h in [(1, 1), (3, 3), (10, 10), (1, 20), (20, 1)]:
dm = mcrfpy.DiscreteMap((w, h), fill=1)
tiles = rs.resolve(dm, seed=0)
assert len(tiles) == w * h, f"Expected {w*h} tiles for {w}x{h}, got {len(tiles)}"
print(" various dimensions: OK")
def test_break_on_match():
"""Test that breakOnMatch prevents later rules from overwriting."""
proj = mcrfpy.LdtkProject("../tests/fixtures/test_project.ldtk")
rs = proj.ruleset("Terrain")
# Create a grid where rule 51 (generic wall) should match
# Rule 51 has breakOnMatch=true, so rule 52 should not override it
dm = mcrfpy.DiscreteMap((3, 3), fill=1) # All walls
tiles = rs.resolve(dm, seed=0)
# All cells should be tile 0 (from rule 51)
center = tiles[4]
assert center == 0, f"Expected tile 0 from rule 51, got {center}"
print(f" break on match: center = {center}: OK")
# Run tests
tests = [
test_basic_resolve,
test_resolve_with_seed,
test_resolve_empty,
test_pattern_negation,
test_resolve_dimensions,
test_break_on_match,
]
passed = 0
failed = 0
print("=== LDtk Resolve Tests ===")
for test in tests:
name = test.__name__
try:
print(f"[TEST] {name}...")
test()
passed += 1
print(f" PASS")
except Exception as e:
failed += 1
print(f" FAIL: {e}")
print(f"\n=== Results: {passed} passed, {failed} failed ===")
if failed > 0:
sys.exit(1)
sys.exit(0)