LDtk import support
This commit is contained in:
parent
322beeaf78
commit
de7778b147
24 changed files with 26203 additions and 0 deletions
|
|
@ -53,6 +53,7 @@ include_directories(${CMAKE_SOURCE_DIR}/src)
|
|||
include_directories(${CMAKE_SOURCE_DIR}/src/3d)
|
||||
include_directories(${CMAKE_SOURCE_DIR}/src/platform)
|
||||
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/json/single_include)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
#include "tiled/PyTileSetFile.h" // Tiled tileset loading
|
||||
#include "tiled/PyTileMapFile.h" // Tiled tilemap loading
|
||||
#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 "GameEngine.h"
|
||||
// ImGui is only available for SFML builds
|
||||
|
|
@ -494,6 +496,10 @@ PyObject* PyInit_mcrfpy()
|
|||
&mcrfpydef::PyTileMapFileType,
|
||||
&mcrfpydef::PyWangSetType,
|
||||
|
||||
/*LDtk project loading*/
|
||||
&mcrfpydef::PyLdtkProjectType,
|
||||
&mcrfpydef::PyAutoRuleSetType,
|
||||
|
||||
nullptr};
|
||||
|
||||
// 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_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
|
||||
PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist);
|
||||
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,30 @@ std::shared_ptr<PyTexture> PyTexture::from_rendered(sf::RenderTexture& render_te
|
|||
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)
|
||||
{
|
||||
// Protect against division by zero if texture failed to load
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ public:
|
|||
|
||||
// #144: Factory method to create texture from rendered content (snapshot)
|
||||
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));
|
||||
int getSpriteCount() const { return sheet_width * sheet_height; }
|
||||
|
||||
|
|
|
|||
187
src/ldtk/AutoRuleResolve.cpp
Normal file
187
src/ldtk/AutoRuleResolve.cpp
Normal 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
|
||||
17
src/ldtk/AutoRuleResolve.h
Normal file
17
src/ldtk/AutoRuleResolve.h
Normal 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
455
src/ldtk/LdtkParse.cpp
Normal 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
12
src/ldtk/LdtkParse.h
Normal 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
119
src/ldtk/LdtkTypes.h
Normal 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
307
src/ldtk/PyAutoRuleSet.cpp
Normal 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
75
src/ldtk/PyAutoRuleSet.h
Normal 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
315
src/ldtk/PyLdtkProject.cpp
Normal 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
72
src/ldtk/PyLdtkProject.h
Normal 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
|
||||
3358
tests/demo/ldtk/Test_file_for_API_showing_all_features.ldtk
Normal file
3358
tests/demo/ldtk/Test_file_for_API_showing_all_features.ldtk
Normal file
File diff suppressed because one or more lines are too long
5055
tests/demo/ldtk/Typical_TopDown_example.ldtk
Normal file
5055
tests/demo/ldtk/Typical_TopDown_example.ldtk
Normal file
File diff suppressed because one or more lines are too long
15200
tests/demo/ldtk/WorldMap_Free_layout.ldtk
Normal file
15200
tests/demo/ldtk/WorldMap_Free_layout.ldtk
Normal file
File diff suppressed because one or more lines are too long
BIN
tests/demo/ldtk/atlas/Inca_front_by_Kronbits-extended.png
Normal file
BIN
tests/demo/ldtk/atlas/Inca_front_by_Kronbits-extended.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
tests/demo/ldtk/atlas/NuclearBlaze_by_deepnight.aseprite
Normal file
BIN
tests/demo/ldtk/atlas/NuclearBlaze_by_deepnight.aseprite
Normal file
Binary file not shown.
BIN
tests/demo/ldtk/atlas/SunnyLand_by_Ansimuz-extended.png
Normal file
BIN
tests/demo/ldtk/atlas/SunnyLand_by_Ansimuz-extended.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
tests/demo/ldtk/atlas/TopDown_by_deepnight.png
Normal file
BIN
tests/demo/ldtk/atlas/TopDown_by_deepnight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
438
tests/demo/screens/ldtk_demo.py
Normal file
438
tests/demo/screens/ldtk_demo.py
Normal 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")
|
||||
183
tests/unit/ldtk_apply_test.py
Normal file
183
tests/unit/ldtk_apply_test.py
Normal 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)
|
||||
222
tests/unit/ldtk_parse_test.py
Normal file
222
tests/unit/ldtk_parse_test.py
Normal 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)
|
||||
146
tests/unit/ldtk_resolve_test.py
Normal file
146
tests/unit/ldtk_resolve_test.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue