diff --git a/docs/模块设计.md b/docs/模块设计.md index ba2bc0ab..49da0e35 100644 --- a/docs/模块设计.md +++ b/docs/模块设计.md @@ -98,7 +98,7 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 - [x] 音频资源 — `sound` - [x] 立体声播放 — `audio`(OpenAL) - [ ] 视频资源 — `video` -- [ ] 字体资源 — `fonts` +- [x] 字体资源 — `font`(`eve.Font` / FreeType → `FontData`) - [x] 3D 模型资源 — `3dmodel`(`model3d` / `eve.Model3D`,medialoader Assimp → `ModelData`) - [ ] 动画资源载入(与 `animation` 配合) - [ ] 统一 cache、引用计数与热重载标记 @@ -178,12 +178,16 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 ### 13. Box2D 物理 — `box2d` -管理 Box2D 物理世界(后期)。 +管理 Box2D 物理世界(LÖVE 风格像素坐标 + `setMeter`)。 -- [ ] World / Body / Fixture 封装 -- [ ] 与 game loop 的 step 同步 -- [ ] 碰撞回调接入 `event` -- [ ] Debug draw(可选) +- [x] World / Body / Fixture 封装 +- [x] 与 game loop 的 step 同步(`World::update(dt)`) +- [x] 碰撞回调接入 `event`(`begincontact` / `endcontact`,参数为 body id) +- [x] Debug draw(可选,`World::drawDebug(gfx)`) + +脚本入口:`physics <- eve.Physics()`;`world <- physics.newWorld(gx, gy)`。 + +代码:[`src/modules/physics/`](../src/modules/physics/) ### 14. 动画系统 — `animation` @@ -269,7 +273,7 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 | 线程 | `thread` | [ ] | | GPU 计算 | `compute` | [ ] | | 粒子 | `particles` | [x] 可用(ECS + 配置热更 + Camera/Canvas) | -| 物理 | `box2d` | [ ] | +| 物理 | `box2d` | [x] 可用(World/Body/Fixture + event 碰撞 + debug draw) | | 动画 | `animation` | [ ] | | 地图 | `map` | [ ] | | 数据库 | `database` | [ ] | diff --git a/docs/测试覆盖.md b/docs/测试覆盖.md index 44512501..ab3853e1 100644 --- a/docs/测试覆盖.md +++ b/docs/测试覆盖.md @@ -126,7 +126,7 @@ | thread | `src/modules/thread/Thread.h` | 部分 | 待展开 | - | | | compute | — | 未实现 | N/A | - | | | particles | `src/modules/particles/` | 可用 | 已展开 | Camera/Canvas + 发射区域/力场 + JSON 热更 | `test/particles.cpp`(18 用例) | -| box2d | — | 未实现 | N/A | - | | +| box2d | `src/modules/physics/` | 可用 | 已展开 | World/Body/Fixture + meter + event 碰撞 | `test/box2d.cpp`(8 用例) | | animation | — | 未实现 | N/A | - | | | map | — | 未实现 | N/A | - | | | database | — | 未实现 | N/A | - | | diff --git a/example/main.nut b/example/main.nut index 3000cd2d..5d9686d6 100644 --- a/example/main.nut +++ b/example/main.nut @@ -1,17 +1,59 @@ // example game callbacks — overrides defaults from load.nut -// Frame: particles.update → clear → sprites → particles.render → ui → present +// Frame: physics → map/particles.update → clear → map → box → particles → debug → present // // Soft hot-reload: put one-time setup in eve_init; guard mutable state so // re-dofile does not wipe C++ entity refs. Optional eve_reload / eve_asset_reload. -if (!("box" in getroottable())) - box <- { x = 100.0, y = 100.0, vx = 60.0 }; if (!("fx" in getroottable())) fx <- null; +if (!("tileLayer" in getroottable())) + tileLayer <- null; +if (!("world" in getroottable())) + world <- null; +if (!("ground" in getroottable())) + ground <- null; +if (!("boxBody" in getroottable())) + boxBody <- null; +if (!("boxW" in getroottable())) + boxW <- 48.0; +if (!("boxH" in getroottable())) + boxH <- 48.0; eve_init = function() { gfx.setBackgroundColor(0.08, 0.09, 0.14, 1.0); if (particles == null) particles = eve.Particles(); + if (map == null) map = eve.Map(); + if (physics == null) physics = eve.Physics(); + physics.setMeter(30.0); + + if (tileLayer == null) { + tileLayer = map.newLayerFromFile("maps/demo.json"); + if (tileLayer == null) { + // Fallback: procedural checkerboard without atlas (solid debug colors). + tileLayer = map.newLayer(10, 7, 32.0, 32.0); + tileLayer.setOrigin(40.0, 80.0); + tileLayer.setLayer(0); + for (local y = 0; y < 7; y += 1) { + for (local x = 0; x < 10; x += 1) { + local gid = ((x + y) % 2 == 0) ? 1 : 2; + if (x == 0 || y == 0 || x == 9 || y == 6) gid = 3; + tileLayer.setTile(x, y, gid); + } + } + } + } + + if (world == null) { + // Gravity in pixels/s² (+Y is down, matching screen coords). + world = physics.newWorld(0.0, 980.0, true); + + ground = world.newBody("static", config.width * 0.5, config.height - 20.0); + ground.newRectangleFixture(config.width * 1.0, 40.0, 0.0, 0.6, 0.0); + + boxBody = world.newBody("dynamic", config.width * 0.5, 80.0); + boxBody.newRectangleFixture(boxW, boxH, 1.0, 0.3, 0.2); + } + if (fx == null) { fx = particles.newEmitterFromFile("particles/fire.json"); if (fx == null) { @@ -27,7 +69,7 @@ eve_init = function() { // Called after scripts soft-reload (not after asset-only changes). eve_reload <- function() { - // Example: tweak live state without recreating emitters. + // Example: tweak live state without recreating emitters / layers. }; // Called when a non-.nut watched file changes (after HotReload.tryReload). @@ -35,14 +77,23 @@ eve_asset_reload <- function(path) { }; eve_update = function(dt) { - box.x = box.x + box.vx * dt; - if (box.x < 0.0 || box.x + 200.0 > config.width) - box.vx = -box.vx; + if (world) world.update(dt); + if (map) map.update(dt); if (particles) particles.update(dt); }; eve_render = function() { gfx.clear(); - gfx.drawSolidRect(box.x, box.y, 200.0, 160.0, 1.3, 0.8, 0.4, 1.0); + if (map) map.render(gfx); + if (boxBody) { + local x = boxBody.getX() - boxW * 0.5; + local y = boxBody.getY() - boxH * 0.5; + gfx.drawSolidRect(x, y, boxW, boxH, 1.3, 0.8, 0.4, 1.0); + } + if (ground) { + gfx.drawSolidRect(0.0, config.height - 40.0, config.width * 1.0, 40.0, 0.25, 0.35, 0.3, 1.0); + } if (particles) particles.render(gfx); + if (config.debug && world) + world.drawDebug(gfx); }; diff --git a/example/maps/demo.json b/example/maps/demo.json new file mode 100644 index 00000000..122e60f0 --- /dev/null +++ b/example/maps/demo.json @@ -0,0 +1,40 @@ +{ + "width": 8, + "height": 6, + "tilewidth": 32, + "tileheight": 32, + "origin": [80, 120], + "autoReload": true, + "layers": [ + { + "type": "tilelayer", + "name": "ground", + "width": 8, + "height": 6, + "layer": 0, + "data": [ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 2, 2, 2, 2, 2, 2, 1, + 1, 2, 3, 3, 3, 3, 2, 1, + 1, 2, 3, 4, 4, 3, 2, 1, + 1, 2, 2, 2, 2, 2, 2, 1, + 1, 1, 1, 1, 1, 1, 1, 1 + ] + }, + { + "type": "tilelayer", + "name": "deco", + "width": 8, + "height": 6, + "layer": 1, + "data": [ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 5, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 6, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ] + } + ] +} diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 7b110755..2b0d8fb4 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -78,43 +78,48 @@ endif() if(WIN32 AND (${CMAKE_BUILD_TYPE} STREQUAL "Debug")) set(ThirdParty ${ThirdParty} simplesquirrel_staticmdd sqstdlib_staticmdd squirrel_staticmdd SDL2d SDL2maind - medialoader_imagemdd medialoader_modelmdd medialoader_soundmdd assimpmdd zlibstaticd physfsmdd lz4mdd + medialoader_imagemdd medialoader_modelmdd medialoader_soundmdd assimpmdd zlibstaticd physfsmdd lz4mdd Box2D vorbis-staticmdd vorbisfile-staticmdd ogg-staticmdd modplug-staticmdd mpg123mdd OpenAL32mdd + freetype PocoFoundationmdd PocoNetmdd PocoJSONmdd PocoXMLmdd ${SystemLibraries}) else() if(WIN32) set(ThirdParty ${ThirdParty} simplesquirrel_staticmd sqstdlib_staticmd squirrel_staticmd SDL2 SDL2main - medialoader_imagemd medialoader_modelmd medialoader_soundmd assimpmd zlibstatic physfsmd lz4md + medialoader_imagemd medialoader_modelmd medialoader_soundmd assimpmd zlibstatic physfsmd lz4md Box2D vorbis-staticmd vorbisfile-staticmd ogg-staticmd modplug-staticmd mpg123md OpenAL32md + freetype PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd ${SystemLibraries}) elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) # SDL2 is shared (loaded by Java); other deps are static where possible. set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main - medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 + medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbis-static vorbisfile-static ogg-static modplug-static mpg123 openal + freetype PocoFoundation PocoNet PocoJSON PocoXML ${SystemLibraries}) elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS OR APPLE) set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main - medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 + medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbis-static vorbisfile-static ogg-static modplug-static mpg123 openal + freetype PocoFoundation PocoNet PocoJSON PocoXML ${SystemLibraries}) else() set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main - medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 + medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbis vorbisfile modplug mpg123 OpenAL + freetype PocoFoundation PocoNet PocoJSON PocoXML ${SystemLibraries}) endif() endif() if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android") OR CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVModel3D EVPlugins) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVMap EVModel3D EVFont EVPlugins EVPhysics) else() - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVModel3D EVPlugins) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVMap EVModel3D EVFont EVPlugins EVPhysics) endif() if(EVENGINE_BUILD_DEMO) diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index af3a6093..8cede3c5 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -49,7 +49,18 @@ target_include_directories(EVUI PUBLIC ${CMAKE_SOURCE_DIR}/third-party/imgui) create_module(EVParticles particles) target_compile_definitions(EVParticles PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +create_module(EVPhysics physics) +# Box2D headers use ; prefer source tree over flattened install. +target_include_directories(EVPhysics PUBLIC ${CMAKE_SOURCE_DIR}/third-party) +create_module(EVMap map) +target_compile_definitions(EVMap PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) create_module(EVModel3D model3d) +create_module(EVFont font) +# ft2build.h lives under include/freetype2/ (installed by third-party FreeType). +target_include_directories(EVFont PRIVATE + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/freetype2 + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/freetype2 +) create_module(EVPlugins plugins) if(EVENGINE_BUILD_DEMO) create_module(EVDemo demo) diff --git a/src/modules/Readme.md b/src/modules/Readme.md index 4b5d9507..78d79eb9 100644 --- a/src/modules/Readme.md +++ b/src/modules/Readme.md @@ -45,9 +45,10 @@ 6. 管理系统 - system 负责系统版本、时间、状态等的查询和设置 -7. 资源管理系统 - image sound video fonts 3dmodel(`model3d`/`Model3D`) animation +7. 资源管理系统 - image sound video font(`font`/`Font`) 3dmodel(`model3d`/`Model3D`) animation 负责加载图形、声音、视频、字体、3d模型、动画等不同格式数据 3dmodel:`Model3D::newModelData` / `newModelDataFromFile` → `ModelData`(medialoader Assimp) + font:`Font::newFontData` / `newFontDataFromFile` → `FontData`(FreeType;glyph → ImageData) 8. 网络系统 - network 封装网络通讯的各种基础操作 @@ -61,8 +62,13 @@ System:`ParticleConfigSystem`(filesystem watch + mtime 回退)/ `ParticleSimSystem` / `ParticleRenderSystem` Module:工厂、`newEmitterFromFile`、脚本绑定;`update` 含 poll+sim -11. Box2d物理 - box2d +11. Box2d物理 - box2d(脚本模块名 `Physics`) 负责管理box2d物理引擎 + Module:`Physics`(`setMeter` / `newWorld`) + 类型:`World` / `Body` / `Fixture`;坐标为像素,内部按 meter 换算 + 帧循环:`world.update(dt)`;碰撞事件:`begincontact` / `endcontact` → `event` + 可选:`world.drawDebug(gfx)` + 代码:`src/modules/physics/`(避免与第三方 `Box2D/` 在大小写不敏感文件系统上冲突) 12. 动画系统 Skeleton Animations diff --git a/src/modules/filesystem/HotReload.cpp b/src/modules/filesystem/HotReload.cpp index b76b1a96..54bf9405 100644 --- a/src/modules/filesystem/HotReload.cpp +++ b/src/modules/filesystem/HotReload.cpp @@ -3,6 +3,8 @@ #include "common/Module.h" #include "filesystem/Filesystem.h" #include "graphics/Graphics.h" +#include "map/TileConfig.h" +#include "map/TileLayer.h" #include "particles/ParticleConfig.h" #include "particles/ParticleEmitter.h" @@ -80,6 +82,20 @@ bool HotReload::reloadParticles(const std::string &normPath) { return reloaded > 0; } +bool HotReload::reloadTilemaps(const std::string &normPath) { + if (ecs::ComponentManager::inst().registy == nullptr) return false; + + int reloaded = 0; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, res] = *it; + if (!cfg->entity || res->path.empty()) continue; + if (normalizePath(res->path) != normPath) continue; + if (map::reloadConfigFile(cfg->entity, nullptr)) ++reloaded; + } + return reloaded > 0; +} + bool HotReload::reloadTextures(const std::string &normPath) { auto *gfx = eve::ModuleManager::getInstance("Graphics"); if (!gfx) return false; @@ -102,6 +118,23 @@ bool HotReload::reloadTextures(const std::string &normPath) { } } } + + // Tile layers that reference this atlas path. + if (ecs::ComponentManager::inst().registy != nullptr) { + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, res, ts] = *it; + if (!cfg->entity || res->texturePath.empty()) continue; + if (normalizePath(res->texturePath) != normPath) continue; + try { + graphics::Texture *tex = gfx->newTextureFromFile(normPath); + cfg->entity->setTileset(tex, ts->firstGid, ts->columns, ts->margin, ts->spacing); + ok = true; + } catch (...) { + } + } + } return ok; } @@ -115,13 +148,16 @@ bool HotReload::tryReload(std::string path) { bool any = false; const bool wantParticle = (kind == "auto" || kind == "particle") && isJsonPath(norm); + const bool wantTilemap = (kind == "auto" || kind == "tilemap") && isJsonPath(norm); const bool wantTexture = (kind == "auto" || kind == "texture") && isImagePath(norm); if (wantParticle) any = reloadParticles(norm) || any; + if (wantTilemap) any = reloadTilemaps(norm) || any; if (wantTexture) any = reloadTextures(norm) || any; - // Bound but unknown extension: still try both when kind is explicit. + // Bound but unknown extension: still try when kind is explicit. if (kind == "particle" && !wantParticle) any = reloadParticles(norm) || any; + if (kind == "tilemap" && !wantTilemap) any = reloadTilemaps(norm) || any; if (kind == "texture" && !wantTexture) any = reloadTextures(norm) || any; return any; diff --git a/src/modules/filesystem/HotReload.h b/src/modules/filesystem/HotReload.h index 69d33f59..d9bd5585 100644 --- a/src/modules/filesystem/HotReload.h +++ b/src/modules/filesystem/HotReload.h @@ -23,14 +23,14 @@ public: HotReload() = default; ~HotReload() override = default; - /** Register a path for explicit reload (kind: "auto"|"particle"|"texture"). */ + /** Register a path for explicit reload (kind: "auto"|"particle"|"tilemap"|"texture"). */ void bind(std::string path, std::string kind = "auto"); void unbind(std::string path); /** * Reload assets matching path (normalized). - * .json → particle emitters with Resource.path - * images → Graphics path-cached texture + emitters with texturePath + * .json → particle emitters / tilemap layers with Resource.path + * images → Graphics path-cached texture + emitters/layers with texturePath * Returns true if anything reloaded. */ bool tryReload(std::string path); @@ -42,6 +42,7 @@ public: private: bool reloadParticles(const std::string &normPath); + bool reloadTilemaps(const std::string &normPath); bool reloadTextures(const std::string &normPath); bool isImagePath(const std::string &normPath) const; bool isJsonPath(const std::string &normPath) const; diff --git a/src/modules/font/Font.cpp b/src/modules/font/Font.cpp new file mode 100644 index 00000000..316b6190 --- /dev/null +++ b/src/modules/font/Font.cpp @@ -0,0 +1,104 @@ +#include "font/Font.h" + +#include "common/Data.h" +#include "common/Exception.h" +#include "filesystem/FileData.h" +#include "filesystem/Filesystem.h" +#include "image/ImageData.h" + +#include + +#include +#include +#include + +namespace eve { +namespace font { + +Module_IMPL(Font, new Font()); + +Font::Font() = default; +Font::~Font() = default; + +FontData *Font::newFontData(Data *data, int size) { + if (data == nullptr || data->getData() == nullptr || data->getSize() == 0) + throw eve::Exception("Cannot decode empty font data"); + + const auto *ptr = static_cast(data->getData()); + std::vector bytes(ptr, ptr + data->getSize()); + + std::string uri; + if (auto *fd = dynamic_cast(data)) + uri = "file://" + fd->getFilename(); + + return new FontData(std::move(bytes), size, std::move(uri)); +} + +FontData *Font::newFontDataFromFile(std::string path, int size) { + if (path.empty()) + throw eve::Exception("Font::newFontDataFromFile: empty path"); + + filesystem::Filesystem *fs = ModuleManager::getInstance("Filesystem"); + if (!fs) + fs = filesystem::Filesystem::create(); + + filesystem::FileData *fd = fs->read(path); + if (fd == nullptr || fd->getData() == nullptr || fd->getSize() == 0) { + delete fd; + throw eve::Exception("Could not read font file: %s", path.c_str()); + } + + try { + FontData *font = newFontData(fd, size); + delete fd; + return font; + } catch (...) { + delete fd; + throw; + } +} + +void Font::expose(ssq::Table &table) { + auto cls = table.addClass(name, Font::create, false); + expose(cls); + + auto fd = table.addClass( + "FontData", std::function([]() -> FontData * { return nullptr; }), true); + fd.addFunc("getSize", &FontData::getSize); + fd.addFunc("getAscent", &FontData::getAscent); + fd.addFunc("getDescent", &FontData::getDescent); + fd.addFunc("getLineHeight", &FontData::getLineHeight); + fd.addFunc("getBaseline", &FontData::getBaseline); + fd.addFunc("getFamilyName", &FontData::getFamilyName); + fd.addFunc("getStyleName", &FontData::getStyleName); + fd.addFunc("getGlyphCount", &FontData::getGlyphCount); + fd.addFunc("hasGlyph", &FontData::hasGlyph); + fd.addFunc("hasGlyphs", &FontData::hasGlyphs); + fd.addFunc("getWidth", &FontData::getWidth); + fd.addFunc("getKerning", &FontData::getKerning); + fd.addFunc("getGlyphWidth", &FontData::getGlyphWidth); + fd.addFunc("getGlyphHeight", &FontData::getGlyphHeight); + fd.addFunc("getGlyphBearingX", &FontData::getGlyphBearingX); + fd.addFunc("getGlyphBearingY", &FontData::getGlyphBearingY); + fd.addFunc("getGlyphAdvance", &FontData::getGlyphAdvance); + fd.addFunc("newGlyphImageData", &FontData::newGlyphImageData); + + // Minimal ImageData surface so newGlyphImageData is usable from scripts. + // Full image decode APIs remain on eve.Image. + auto img = table.addClass( + "ImageData", std::function([]() -> image::ImageData * { return nullptr; }), + true); + img.addFunc("getWidth", &image::ImageData::getWidth); + img.addFunc("getHeight", &image::ImageData::getHeight); + img.addFunc("getFormat", &image::ImageData::getFormat); + img.addFunc("getSize", &image::ImageData::getSize); +} + +void Font::expose(ssq::Class &cls) { + cls.addFunc("getName", &Font::getName); + cls.addFunc("newFontData", &Font::newFontData); + cls.addFunc("newFontDataFromFile", &Font::newFontDataFromFile); +} + +} // namespace font +} // namespace eve diff --git a/src/modules/font/Font.h b/src/modules/font/Font.h new file mode 100644 index 00000000..06b89b12 --- /dev/null +++ b/src/modules/font/Font.h @@ -0,0 +1,38 @@ +#pragma once + +#include "common/Module.h" +#include "font/FontData.h" + +#include + +namespace eve { +class Data; + +namespace font { + +/** + * Resource module for decoding TrueType / OpenType fonts via FreeType. + * Produces FontData (CPU metrics + glyph raster); GPU upload/draw is graphics'. + */ +class Font : public Module { +public: + Module_REG(Font); + + Font(); + ~Font() override; + + /** + * Decode a font from in-memory bytes at the given pixel size. + * @param data Encoded font bytes (e.g. FileData / ByteData). + * @param size Pixel height (default 16). + */ + FontData *newFontData(Data *data, int size = 16); + + /** + * Decode a font from a VFS path (physfs) at the given pixel size. + */ + FontData *newFontDataFromFile(std::string path, int size = 16); +}; + +} // namespace font +} // namespace eve diff --git a/src/modules/font/FontData.cpp b/src/modules/font/FontData.cpp new file mode 100644 index 00000000..d8b39469 --- /dev/null +++ b/src/modules/font/FontData.cpp @@ -0,0 +1,292 @@ +#include "font/FontData.h" + +#include "common/Exception.h" +#include "image/ImageData.h" + +#include +#include FT_FREETYPE_H +#include FT_GLYPH_H + +#include +#include + +namespace eve { +namespace font { + +namespace { + +FT_Library &ftLibrary() { + static struct Holder { + FT_Library lib = nullptr; + Holder() { + if (FT_Init_FreeType(&lib) != 0) + lib = nullptr; + } + ~Holder() { + if (lib) + FT_Done_FreeType(lib); + } + } holder; + return holder.lib; +} + +// Decode one UTF-8 codepoint; advances `i`. Returns 0 on invalid / truncated. +uint32_t nextCodepoint(const std::string &text, size_t &i) { + if (i >= text.size()) + return 0; + const auto *s = reinterpret_cast(text.data() + i); + const size_t rem = text.size() - i; + unsigned char c0 = s[0]; + if (c0 < 0x80) { + i += 1; + return c0; + } + if ((c0 & 0xE0) == 0xC0 && rem >= 2 && (s[1] & 0xC0) == 0x80) { + i += 2; + return ((c0 & 0x1F) << 6) | (s[1] & 0x3F); + } + if ((c0 & 0xF0) == 0xE0 && rem >= 3 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80) { + i += 3; + return ((c0 & 0x0F) << 12) | ((s[1] & 0x3F) << 6) | (s[2] & 0x3F); + } + if ((c0 & 0xF8) == 0xF0 && rem >= 4 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80 && + (s[3] & 0xC0) == 0x80) { + i += 4; + return ((c0 & 0x07) << 18) | ((s[1] & 0x3F) << 12) | ((s[2] & 0x3F) << 6) | (s[3] & 0x3F); + } + i += 1; + return 0xFFFD; +} + +} // namespace + +FontData::FontData(std::vector fontBytes, int size, std::string uri) + : Resource(std::move(uri)), bytes(std::move(fontBytes)), pixelSize(size) { + if (bytes.empty()) + throw eve::Exception("Cannot decode empty font data"); + if (pixelSize <= 0) + throw eve::Exception("Font pixel size must be positive"); + + FT_Library lib = ftLibrary(); + if (!lib) + throw eve::Exception("FreeType library init failed"); + + FT_Error err = FT_New_Memory_Face(lib, bytes.data(), static_cast(bytes.size()), 0, &face); + if (err != 0 || !face) + throw eve::Exception("Could not decode font data (FreeType error %d)", static_cast(err)); + + err = FT_Set_Pixel_Sizes(face, 0, static_cast(pixelSize)); + if (err != 0) { + FT_Done_Face(face); + face = nullptr; + throw eve::Exception("Could not set font pixel size (FreeType error %d)", static_cast(err)); + } + + glyphCache.resize(static_cast(std::max(face->num_glyphs, 1))); +} + +FontData::~FontData() { + if (face) { + FT_Done_Face(face); + face = nullptr; + } +} + +int FontData::getSize() const { return pixelSize; } + +float FontData::getAscent() const { + if (!face) + return 0.f; + return static_cast(face->size->metrics.ascender) / 64.f; +} + +float FontData::getDescent() const { + if (!face) + return 0.f; + // FreeType descent is typically negative; expose as negative for layout. + return static_cast(face->size->metrics.descender) / 64.f; +} + +float FontData::getLineHeight() const { + if (!face) + return static_cast(pixelSize); + return static_cast(face->size->metrics.height) / 64.f; +} + +float FontData::getBaseline() const { return getAscent(); } + +std::string FontData::getFamilyName() const { + if (!face || !face->family_name) + return {}; + return face->family_name; +} + +std::string FontData::getStyleName() const { + if (!face || !face->style_name) + return {}; + return face->style_name; +} + +int FontData::getGlyphCount() const { + if (!face) + return 0; + return static_cast(face->num_glyphs); +} + +unsigned int FontData::glyphIndex(int codepoint) const { + if (!face || codepoint < 0) + return 0; + return FT_Get_Char_Index(face, static_cast(codepoint)); +} + +bool FontData::hasGlyph(int codepoint) const { + return glyphIndex(codepoint) != 0; +} + +bool FontData::hasGlyphs(std::string text) const { + size_t i = 0; + while (i < text.size()) { + uint32_t cp = nextCodepoint(text, i); + if (cp == 0) + continue; + if (!hasGlyph(static_cast(cp))) + return false; + } + return true; +} + +float FontData::getKerning(int leftCodepoint, int rightCodepoint) const { + if (!face || !FT_HAS_KERNING(face)) + return 0.f; + FT_UInt left = glyphIndex(leftCodepoint); + FT_UInt right = glyphIndex(rightCodepoint); + if (left == 0 || right == 0) + return 0.f; + FT_Vector delta; + if (FT_Get_Kerning(face, left, right, FT_KERNING_DEFAULT, &delta) != 0) + return 0.f; + return static_cast(delta.x) / 64.f; +} + +const FontData::GlyphCache &FontData::ensureGlyph(int codepoint) const { + static GlyphCache missing; + if (!face) + return missing; + + FT_UInt index = glyphIndex(codepoint); + + if (index >= glyphCache.size()) { + glyphCache.resize(index + 1); + } + + GlyphCache &slot = glyphCache[index]; + if (slot.loaded) + return slot; + + FT_Error err = FT_Load_Glyph(face, index, FT_LOAD_DEFAULT); + if (err != 0) { + slot.loaded = true; + slot.empty = true; + return slot; + } + + slot.advance = static_cast((face->glyph->advance.x + 32) >> 6); + slot.bearingX = face->glyph->bitmap_left; + slot.bearingY = face->glyph->bitmap_top; + slot.width = static_cast((face->glyph->metrics.width + 63) >> 6); + slot.height = static_cast((face->glyph->metrics.height + 63) >> 6); + slot.empty = (index == 0 && codepoint != 0); + slot.loaded = true; + return slot; +} + +float FontData::getWidth(std::string text) const { + float width = 0.f; + int prev = -1; + size_t i = 0; + while (i < text.size()) { + uint32_t cp = nextCodepoint(text, i); + if (cp == 0) + continue; + int code = static_cast(cp); + if (prev >= 0) + width += getKerning(prev, code); + width += static_cast(ensureGlyph(code).advance); + prev = code; + } + return width; +} + +int FontData::getGlyphWidth(int codepoint) const { return ensureGlyph(codepoint).width; } +int FontData::getGlyphHeight(int codepoint) const { return ensureGlyph(codepoint).height; } +int FontData::getGlyphBearingX(int codepoint) const { return ensureGlyph(codepoint).bearingX; } +int FontData::getGlyphBearingY(int codepoint) const { return ensureGlyph(codepoint).bearingY; } +int FontData::getGlyphAdvance(int codepoint) const { return ensureGlyph(codepoint).advance; } + +image::ImageData *FontData::newGlyphImageData(int codepoint) { + if (!face) + return new image::ImageData(0, 0, "RGBA8"); + + FT_UInt index = glyphIndex(codepoint); + FT_Error err = FT_Load_Glyph(face, index, FT_LOAD_RENDER); + if (err != 0 || !face->glyph->bitmap.buffer || face->glyph->bitmap.width == 0 || + face->glyph->bitmap.rows == 0) { + // Still refresh metric cache. + ensureGlyph(codepoint); + return new image::ImageData(0, 0, "RGBA8"); + } + + const FT_Bitmap &bm = face->glyph->bitmap; + const int w = static_cast(bm.width); + const int h = static_cast(bm.rows); + + // Update cache with rendered metrics. + if (index >= glyphCache.size()) + glyphCache.resize(index + 1); + GlyphCache &slot = glyphCache[index]; + slot.width = w; + slot.height = h; + slot.bearingX = face->glyph->bitmap_left; + slot.bearingY = face->glyph->bitmap_top; + slot.advance = static_cast((face->glyph->advance.x + 32) >> 6); + slot.empty = false; + slot.loaded = true; + + auto *img = new image::ImageData(w, h, "RGBA8"); + auto *dst = static_cast(img->getData()); + + if (bm.pixel_mode == FT_PIXEL_MODE_GRAY) { + for (int y = 0; y < h; ++y) { + const unsigned char *src = bm.buffer + y * bm.pitch; + unsigned char *row = dst + y * w * 4; + for (int x = 0; x < w; ++x) { + unsigned char a = src[x]; + row[x * 4 + 0] = 255; + row[x * 4 + 1] = 255; + row[x * 4 + 2] = 255; + row[x * 4 + 3] = a; + } + } + } else if (bm.pixel_mode == FT_PIXEL_MODE_MONO) { + for (int y = 0; y < h; ++y) { + const unsigned char *src = bm.buffer + y * bm.pitch; + unsigned char *row = dst + y * w * 4; + for (int x = 0; x < w; ++x) { + unsigned char byte = src[x >> 3]; + unsigned char a = (byte & (0x80 >> (x & 7))) ? 255 : 0; + row[x * 4 + 0] = 255; + row[x * 4 + 1] = 255; + row[x * 4 + 2] = 255; + row[x * 4 + 3] = a; + } + } + } else { + // Unsupported pixel mode — leave transparent. + std::memset(dst, 0, static_cast(w) * static_cast(h) * 4u); + } + + return img; +} + +} // namespace font +} // namespace eve diff --git a/src/modules/font/FontData.h b/src/modules/font/FontData.h new file mode 100644 index 00000000..03db801f --- /dev/null +++ b/src/modules/font/FontData.h @@ -0,0 +1,76 @@ +#pragma once + +#include "common/Resource.h" + +#include +#include +#include + +struct FT_FaceRec_; +typedef struct FT_FaceRec_ *FT_Face; + +namespace eve { +namespace image { +class ImageData; +} + +namespace font { + +/** + * CPU-side decoded font face (FreeType FT_Face + owned font bytes). + * Does not upload to GPU — rasterize glyphs to ImageData for graphics::Texture. + */ +class FontData : public Resource { +public: + FontData(std::vector bytes, int pixelSize, std::string uri = ""); + ~FontData() override; + + int getSize() const; + float getAscent() const; + float getDescent() const; + float getLineHeight() const; + float getBaseline() const; + + std::string getFamilyName() const; + std::string getStyleName() const; + int getGlyphCount() const; + + bool hasGlyph(int codepoint) const; + bool hasGlyphs(std::string text) const; + float getWidth(std::string text) const; + float getKerning(int leftCodepoint, int rightCodepoint) const; + + int getGlyphWidth(int codepoint) const; + int getGlyphHeight(int codepoint) const; + int getGlyphBearingX(int codepoint) const; + int getGlyphBearingY(int codepoint) const; + int getGlyphAdvance(int codepoint) const; + + /** + * Rasterize one glyph to RGBA8 ImageData (RGB white, A from FreeType gray). + * Returns a 0x0 ImageData if the codepoint is missing / empty. + */ + image::ImageData *newGlyphImageData(int codepoint); + +private: + struct GlyphCache { + int width = 0; + int height = 0; + int bearingX = 0; + int bearingY = 0; + int advance = 0; + bool loaded = false; + bool empty = true; + }; + + const GlyphCache &ensureGlyph(int codepoint) const; + unsigned int glyphIndex(int codepoint) const; + + std::vector bytes; + FT_Face face = nullptr; + int pixelSize = 16; + mutable std::vector glyphCache; // indexed by glyph index +}; + +} // namespace font +} // namespace eve diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index 581c08b7..da43b90f 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -81,6 +81,15 @@ public: virtual void drawTexturedRectShader(Texture *texture, Shader *shader, float x, float y, float w, float h, const Color &color) = 0; + /** Draw a textured sub-rect (atlas / tile UVs). texture may be null → solid. */ + virtual void drawTexturedRectUV(Texture *texture, float x, float y, float w, float h, float u0, + float v0, float u1, float v1, const Color &color) = 0; + + /** UV draw with an explicit Shader (nullptr = default textured pipeline). */ + virtual void drawTexturedRectShaderUV(Texture *texture, Shader *shader, float x, float y, + float w, float h, float u0, float v0, float u1, float v1, + const Color &color) = 0; + /** Upload triangulated mesh from Assimp (pos/normal/uv + indices). Owned by Graphics. */ virtual Mesh *newMeshFromAssimp(const ::aiMesh &mesh) = 0; diff --git a/src/modules/graphics/vulkan/Graphics.cpp b/src/modules/graphics/vulkan/Graphics.cpp index e070e4f0..35bdc44f 100644 --- a/src/modules/graphics/vulkan/Graphics.cpp +++ b/src/modules/graphics/vulkan/Graphics.cpp @@ -974,11 +974,22 @@ bool Graphics::reloadTextureFromFile(const std::string &filename) { } void Graphics::drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) { - drawTexturedRectShader(texture, currentShader, x, y, w, h, color); + drawTexturedRectUV(texture, x, y, w, h, 0.f, 0.f, 1.f, 1.f, color); } void Graphics::drawTexturedRectShader(Texture *texture, Shader *shader, float x, float y, float w, float h, const Color &color) { + drawTexturedRectShaderUV(texture, shader, x, y, w, h, 0.f, 0.f, 1.f, 1.f, color); +} + +void Graphics::drawTexturedRectUV(Texture *texture, float x, float y, float w, float h, float u0, + float v0, float u1, float v1, const Color &color) { + drawTexturedRectShaderUV(texture, currentShader, x, y, w, h, u0, v0, u1, v1, color); +} + +void Graphics::drawTexturedRectShaderUV(Texture *texture, Shader *shader, float x, float y, float w, + float h, float u0, float v0, float u1, float v1, + const Color &color) { if (!texture) { drawSolidRect(x, y, w, h, color); return; @@ -987,7 +998,7 @@ void Graphics::drawTexturedRectShader(Texture *texture, Shader *shader, float x, texturedBatches.back().shader != shader) { texturedBatches.push_back(TexturedBatch{texture, shader, Batcher{}}); } - texturedBatches.back().batch.addTexturedRect(x, y, w, h, color, 0, 0, 1, 1); + texturedBatches.back().batch.addTexturedRect(x, y, w, h, color, u0, v0, u1, v1); } namespace { diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index c23be8b2..7589b7d8 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -126,6 +126,11 @@ public: void drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) override; void drawTexturedRectShader(Texture *texture, Shader *shader, float x, float y, float w, float h, const Color &color) override; + void drawTexturedRectUV(Texture *texture, float x, float y, float w, float h, float u0, float v0, + float u1, float v1, const Color &color) override; + void drawTexturedRectShaderUV(Texture *texture, Shader *shader, float x, float y, float w, + float h, float u0, float v0, float u1, float v1, + const Color &color) override; Shader *newShaderFromSpv(const std::vector &vertSpv, const std::vector &fragSpv) override; Shader *newShaderFromSpvFile(const std::string &vertPath, const std::string &fragPath) override; diff --git a/src/modules/map/Map.cpp b/src/modules/map/Map.cpp new file mode 100644 index 00000000..cf55ad62 --- /dev/null +++ b/src/modules/map/Map.cpp @@ -0,0 +1,94 @@ +#include "map/Map.h" +#include "map/TileSystem.h" +#include "map/TileConfig.h" +#include "graphics/Graphics.h" +#include "common/Module.h" + +#include + +namespace eve::map { + +Module_IMPL(Map, new Map()); + +TileLayer *Map::newLayer(int mapW, int mapH, float tileW, float tileH) { + return TileLayer::createLayer(mapW, mapH, tileW, tileH); +} + +TileLayer *Map::newLayerFromFile(const std::string &path) { + auto layers = loadMapFile(path, nullptr); + return layers.empty() ? nullptr : layers.front(); +} + +int Map::loadFromFile(const std::string &path) { + auto layers = loadMapFile(path, nullptr); + return int(layers.size()); +} + +void Map::update(float dt) { + (void)dt; + TileConfigSystem::poll(); +} + +void Map::render(graphics::Graphics *gfx) { TileRenderSystem::render(gfx); } + +int Map::pollConfigs() { return TileConfigSystem::poll(); } + +int Map::getLayerCount() const { + if (ecs::ComponentManager::inst().registy == nullptr) return 0; + int n = 0; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) ++n; + return n; +} + +void Map::expose(ssq::Table &table) { + auto cls = table.addClass(name, Map::create, false); + expose(cls); + + auto layer = table.addClass( + "TileLayer", std::function([]() -> TileLayer * { return nullptr; }), true); + layer.addFunc("setOrigin", &TileLayer::setOrigin); + layer.addFunc("getX", &TileLayer::getX); + layer.addFunc("getY", &TileLayer::getY); + layer.addFunc("getMapWidth", &TileLayer::getMapWidth); + layer.addFunc("getMapHeight", &TileLayer::getMapHeight); + layer.addFunc("getTileWidth", &TileLayer::getTileWidth); + layer.addFunc("getTileHeight", &TileLayer::getTileHeight); + layer.addFunc("setTileSize", &TileLayer::setTileSize); + layer.addFunc("resize", &TileLayer::resize); + layer.addFunc("setTile", &TileLayer::setTile); + layer.addFunc("getTile", &TileLayer::getTile); + layer.addFunc("fill", &TileLayer::fill); + layer.addFunc("clear", &TileLayer::clear); + layer.addFunc("setTileset", &TileLayer::setTileset); + layer.addFunc("setTilesetTileSize", &TileLayer::setTilesetTileSize); + layer.addFunc("getTilesetTexture", &TileLayer::getTilesetTexture); + layer.addFunc("getTilesetFirstGid", &TileLayer::getTilesetFirstGid); + layer.addFunc("getTilesetColumns", &TileLayer::getTilesetColumns); + layer.addFunc("setCanvas", &TileLayer::setCanvas); + layer.addFunc("setCamera", &TileLayer::setCamera); + layer.addFunc("setLayer", &TileLayer::setLayer); + layer.addFunc("getLayer", &TileLayer::getLayer); + layer.addFunc("setVisible", &TileLayer::setVisible); + layer.addFunc("isVisible", &TileLayer::isVisible); + layer.addFunc("setTint", &TileLayer::setTint); + layer.addFunc("applyConfig", &TileLayer::applyConfig); + layer.addFunc("loadConfig", &TileLayer::loadConfig); + layer.addFunc("reloadConfig", &TileLayer::reloadConfig); + layer.addFunc("setAutoReload", &TileLayer::setAutoReload); + layer.addFunc("getAutoReload", &TileLayer::getAutoReload); + layer.addFunc("getConfigPath", &TileLayer::getConfigPath); +} + +void Map::expose(ssq::Class &cls) { + cls.addFunc("getName", &Map::getName); + cls.addFunc("newLayer", &Map::newLayer); + cls.addFunc("newLayerFromFile", &Map::newLayerFromFile); + cls.addFunc("loadFromFile", &Map::loadFromFile); + cls.addFunc("update", &Map::update); + cls.addFunc("render", &Map::render); + cls.addFunc("pollConfigs", &Map::pollConfigs); + cls.addFunc("getLayerCount", &Map::getLayerCount); +} + +} // namespace eve::map diff --git a/src/modules/map/Map.h b/src/modules/map/Map.h new file mode 100644 index 00000000..64797761 --- /dev/null +++ b/src/modules/map/Map.h @@ -0,0 +1,43 @@ +#pragma once + +#include "common/Module.h" +#include "map/TileLayer.h" + +#include +#include + +namespace eve::graphics { +class Graphics; +} + +namespace eve::map { + +/** + * Map module — factory + script binding for 2D tilemaps. + * Per-frame: TileConfigSystem (hot reload) → TileRenderSystem. + */ +class Map : public Module { +public: + Module_REG(Map); + Map() = default; + ~Map() override = default; + + TileLayer *newLayer(int mapW, int mapH, float tileW = 32.f, float tileH = 32.f); + + /** + * Load map JSON (Tiled-compatible orthogonal subset or EVEngine simplified format). + * Creates one TileLayer per tile layer; returns the first (nullptr on failure). + */ + TileLayer *newLayerFromFile(const std::string &path); + + /** Same as newLayerFromFile but returns how many layers were created (0 on failure). */ + int loadFromFile(const std::string &path); + + void update(float dt); + void render(graphics::Graphics *gfx); + int pollConfigs(); + + int getLayerCount() const; +}; + +} // namespace eve::map diff --git a/src/modules/map/TileConfig.cpp b/src/modules/map/TileConfig.cpp new file mode 100644 index 00000000..384268b4 --- /dev/null +++ b/src/modules/map/TileConfig.cpp @@ -0,0 +1,495 @@ +#include "map/TileConfig.h" + +#include "common/Module.h" +#include "data/DataModule.h" +#include "data/JsonDocument.h" +#include "filesystem/Filesystem.h" +#include "filesystem/FileData.h" +#include "filesystem/HotReload.h" +#include "graphics/Graphics.h" + +#include +#include +#include + +#include +#include + +namespace eve::map { +namespace { + +float asFloat(const Poco::Dynamic::Var &v, float fallback) { + try { + if (v.isEmpty()) return fallback; + return static_cast(v.convert()); + } catch (...) { + return fallback; + } +} + +int asInt(const Poco::Dynamic::Var &v, int fallback) { + try { + if (v.isEmpty()) return fallback; + return v.convert(); + } catch (...) { + return fallback; + } +} + +bool asBool(const Poco::Dynamic::Var &v, bool fallback) { + try { + if (v.isEmpty()) return fallback; + return v.convert(); + } catch (...) { + return fallback; + } +} + +std::string asString(const Poco::Dynamic::Var &v) { + try { + if (v.isEmpty()) return {}; + return v.convert(); + } catch (...) { + return {}; + } +} + +bool readVec2(Poco::JSON::Object::Ptr o, const char *key, float &a, float &b) { + if (!o || !o->has(key)) return false; + Poco::JSON::Array::Ptr arr; + try { + arr = o->getArray(key); + } catch (...) { + return false; + } + if (!arr || arr->size() < 2) return false; + a = asFloat(arr->get(0), a); + b = asFloat(arr->get(1), b); + return true; +} + +bool readVec4(Poco::JSON::Object::Ptr o, const char *key, float &a, float &b, float &c, float &d) { + if (!o || !o->has(key)) return false; + Poco::JSON::Array::Ptr arr; + try { + arr = o->getArray(key); + } catch (...) { + return false; + } + if (!arr || arr->size() < 4) return false; + a = asFloat(arr->get(0), a); + b = asFloat(arr->get(1), b); + c = asFloat(arr->get(2), c); + d = asFloat(arr->get(3), d); + return true; +} + +int64_t fileModtime(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + eve::filesystem::Filesystem::Info info{}; + if (!fs->getInfo(path, info)) return -1; + return info.modtime; +} + +graphics::Texture *tryLoadTexture(const std::string &path) { + if (path.empty()) return nullptr; + auto *gfx = eve::ModuleManager::getInstance("Graphics"); + if (!gfx) return nullptr; + try { + graphics::Texture *tex = gfx->newTextureFromFile(path); + if (auto *hot = eve::ModuleManager::getInstance("HotReload")) + hot->bind(path, "texture"); + return tex; + } catch (...) { + return nullptr; + } +} + +struct TilesetInfo { + std::string image; + int firstGid = 1; + int columns = 1; + int tileW = 32; + int tileH = 32; + int margin = 0; + int spacing = 0; +}; + +TilesetInfo readTilesetObject(Poco::JSON::Object::Ptr o) { + TilesetInfo info; + if (!o) return info; + if (o->has("image")) info.image = asString(o->get("image")); + else if (o->has("texture")) info.image = asString(o->get("texture")); + if (o->has("firstgid")) info.firstGid = asInt(o->get("firstgid"), 1); + else if (o->has("firstGid")) info.firstGid = asInt(o->get("firstGid"), 1); + if (o->has("columns")) info.columns = asInt(o->get("columns"), 1); + if (o->has("tilewidth")) info.tileW = asInt(o->get("tilewidth"), 32); + else if (o->has("tileWidth")) info.tileW = asInt(o->get("tileWidth"), 32); + if (o->has("tileheight")) info.tileH = asInt(o->get("tileheight"), 32); + else if (o->has("tileHeight")) info.tileH = asInt(o->get("tileHeight"), 32); + if (o->has("margin")) info.margin = asInt(o->get("margin"), 0); + if (o->has("spacing")) info.spacing = asInt(o->get("spacing"), 0); + if (info.columns <= 0 && o->has("imagewidth") && info.tileW > 0) { + const int iw = asInt(o->get("imagewidth"), 0); + if (iw > 0) info.columns = std::max(1, (iw - info.margin) / (info.tileW + info.spacing)); + } + return info; +} + +void applyTileset(TileLayer *layer, const TilesetInfo &info) { + if (!layer) return; + layer->setTilesetTileSize(info.tileW, info.tileH); + graphics::Texture *tex = tryLoadTexture(info.image); + layer->resource()->texturePath = info.image; + layer->setTileset(tex, info.firstGid, info.columns, info.margin, info.spacing); +} + +bool readDataArray(Poco::JSON::Object::Ptr layerObj, std::vector &out) { + if (!layerObj || !layerObj->has("data")) return false; + Poco::JSON::Array::Ptr arr; + try { + arr = layerObj->getArray("data"); + } catch (...) { + return false; + } + if (!arr) return false; + out.resize(arr->size()); + for (size_t i = 0; i < arr->size(); ++i) out[i] = uint32_t(asInt(arr->get(i), 0)); + return true; +} + +void applyLayerDraw(TileLayer *layer, Poco::JSON::Object::Ptr layerObj) { + if (!layer || !layerObj) return; + if (layerObj->has("visible")) + layer->setVisible(asBool(layerObj->get("visible"), true)); + // Tiled uses "opacity"; EVEngine also accepts "layer" for render sort. + if (layerObj->has("layer")) + layer->setLayer(asInt(layerObj->get("layer"), layer->getLayer())); + else if (layerObj->has("id")) + layer->setLayer(asInt(layerObj->get("id"), layer->getLayer())); + float r = 1, g = 1, b = 1, a = 1; + if (readVec4(layerObj, "tint", r, g, b, a)) + layer->setTint(r, g, b, a); + else if (layerObj->has("opacity")) { + a = asFloat(layerObj->get("opacity"), 1.f); + layer->setTint(1.f, 1.f, 1.f, a); + } + float ox = layer->getX(), oy = layer->getY(); + if (layerObj->has("x")) ox = asFloat(layerObj->get("x"), ox); + if (layerObj->has("offsetx")) ox += asFloat(layerObj->get("offsetx"), 0.f); + if (layerObj->has("y")) oy = asFloat(layerObj->get("y"), oy); + if (layerObj->has("offsety")) oy += asFloat(layerObj->get("offsety"), 0.f); + if (layerObj->has("x") || layerObj->has("y") || layerObj->has("offsetx") || + layerObj->has("offsety")) + layer->setOrigin(ox, oy); +} + +bool isTileLayerObject(Poco::JSON::Object::Ptr o) { + if (!o) return false; + if (o->has("type")) { + const std::string t = asString(o->get("type")); + if (!t.empty() && t != "tilelayer") return false; + } + return o->has("data"); +} + +void applyMapGlobals(TileLayer *layer, Poco::JSON::Object::Ptr root) { + if (!layer || !root) return; + + int mapW = layer->getMapWidth(); + int mapH = layer->getMapHeight(); + float tileW = layer->getTileWidth(); + float tileH = layer->getTileHeight(); + + if (root->has("width")) mapW = asInt(root->get("width"), mapW); + if (root->has("height")) mapH = asInt(root->get("height"), mapH); + if (root->has("tilewidth")) tileW = asFloat(root->get("tilewidth"), tileW); + else if (root->has("tileWidth")) tileW = asFloat(root->get("tileWidth"), tileW); + if (root->has("tileheight")) tileH = asFloat(root->get("tileheight"), tileH); + else if (root->has("tileHeight")) tileH = asFloat(root->get("tileHeight"), tileH); + + if (mapW != layer->getMapWidth() || mapH != layer->getMapHeight()) layer->resize(mapW, mapH); + layer->setTileSize(tileW, tileH); + + float ox = layer->getX(), oy = layer->getY(); + if (readVec2(root, "origin", ox, oy)) + layer->setOrigin(ox, oy); + else { + if (root->has("x")) ox = asFloat(root->get("x"), ox); + if (root->has("y")) oy = asFloat(root->get("y"), oy); + if (root->has("x") || root->has("y")) layer->setOrigin(ox, oy); + } + + if (root->has("layer")) + layer->setLayer(asInt(root->get("layer"), layer->getLayer())); + if (root->has("visible")) + layer->setVisible(asBool(root->get("visible"), layer->isVisible())); + if (root->has("autoReload")) + layer->resource()->autoReload = asBool(root->get("autoReload"), true); + + float r = 1, g = 1, b = 1, a = 1; + if (readVec4(root, "tint", r, g, b, a)) layer->setTint(r, g, b, a); + + // Single tileset object / image shortcut. + if (root->has("tileset") && !root->isArray("tileset")) { + try { + applyTileset(layer, readTilesetObject(root->getObject("tileset"))); + } catch (...) { + } + } else if (root->has("tilesets")) { + try { + auto arr = root->getArray("tilesets"); + if (arr && arr->size() > 0) { + try { + applyTileset(layer, readTilesetObject(arr->getObject(0))); + } catch (...) { + } + } + } catch (...) { + } + } else if (root->has("image") || root->has("texture")) { + TilesetInfo info = readTilesetObject(root); + applyTileset(layer, info); + } +} + +bool applyFlatLayerData(TileLayer *layer, Poco::JSON::Object::Ptr root) { + // Flat single-layer: data at root. + std::vector data; + if (!readDataArray(root, data)) return false; + const int mapW = layer->getMapWidth(); + const int mapH = layer->getMapHeight(); + const size_t need = size_t(std::max(0, mapW) * std::max(0, mapH)); + if (need == 0) return false; + auto &gids = layer->tiles()->gids; + gids.assign(need, 0u); + const size_t n = std::min(need, data.size()); + for (size_t i = 0; i < n; ++i) gids[i] = data[i]; + return true; +} + +bool applyOneLayerObject(TileLayer *layer, Poco::JSON::Object::Ptr layerObj, int mapW, int mapH) { + if (!layer || !layerObj) return false; + int w = mapW, h = mapH; + if (layerObj->has("width")) w = asInt(layerObj->get("width"), w); + if (layerObj->has("height")) h = asInt(layerObj->get("height"), h); + if (w != layer->getMapWidth() || h != layer->getMapHeight()) layer->resize(w, h); + + std::vector data; + if (!readDataArray(layerObj, data)) return false; + const size_t need = size_t(std::max(0, layer->getMapWidth()) * std::max(0, layer->getMapHeight())); + auto &gids = layer->tiles()->gids; + gids.assign(need, 0u); + const size_t n = std::min(need, data.size()); + for (size_t i = 0; i < n; ++i) gids[i] = data[i]; + + applyLayerDraw(layer, layerObj); + return true; +} + +} // namespace + +bool applyConfigDocument(TileLayer *layer, data::JsonDocument *doc) { + if (!layer || !doc || !doc->isObject()) return false; + auto root = doc->object(); + if (!root) return false; + + applyMapGlobals(layer, root); + + // Prefer nested layers[0]; fall back to root.data. + if (root->has("layers")) { + try { + auto arr = root->getArray("layers"); + if (arr) { + for (size_t i = 0; i < arr->size(); ++i) { + Poco::JSON::Object::Ptr lo; + try { + lo = arr->getObject(i); + } catch (...) { + continue; + } + if (!isTileLayerObject(lo)) continue; + return applyOneLayerObject(layer, lo, layer->getMapWidth(), + layer->getMapHeight()); + } + } + } catch (...) { + } + } + + if (root->has("data")) return applyFlatLayerData(layer, root); + + // Globals-only config still succeeds (empty map). + return true; +} + +bool applyConfigText(TileLayer *layer, const std::string &json, std::string *error) { + auto *dm = eve::data::DataModule::create(); + std::string err; + std::unique_ptr doc(dm->decodeJson(json, &err)); + if (!doc) { + if (error) *error = err.empty() ? "invalid json" : err; + return false; + } + if (!applyConfigDocument(layer, doc.get())) { + if (error) *error = "config root must be object"; + return false; + } + return true; +} + +bool loadConfigFile(TileLayer *layer, const std::string &path, std::string *error) { + if (!layer || path.empty()) { + if (error) *error = "empty path"; + return false; + } + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + + std::unique_ptr data; + try { + data.reset(fs->read(path)); + } catch (...) { + if (error) *error = "read failed: " + path; + return false; + } + if (!data || data->getSize() == 0) { + if (error) *error = "empty file: " + path; + return false; + } + + std::string text(static_cast(data->getData()), data->getSize()); + if (!applyConfigText(layer, text, error)) return false; + + auto res = layer->resource(); + res->path = path; + res->modtime = fileModtime(path); + fs->watch(path); + if (auto *hot = eve::ModuleManager::getInstance("HotReload")) + hot->bind(path, "tilemap"); + return true; +} + +bool reloadConfigFile(TileLayer *layer, std::string *error) { + if (!layer) return false; + const std::string &path = layer->resource()->path; + if (path.empty()) { + if (error) *error = "no config path"; + return false; + } + return loadConfigFile(layer, path, error); +} + +std::vector loadMapFile(const std::string &path, std::string *error) { + std::vector out; + if (path.empty()) { + if (error) *error = "empty path"; + return out; + } + + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + + std::unique_ptr data; + try { + data.reset(fs->read(path)); + } catch (...) { + if (error) *error = "read failed: " + path; + return out; + } + if (!data || data->getSize() == 0) { + if (error) *error = "empty file: " + path; + return out; + } + + std::string text(static_cast(data->getData()), data->getSize()); + auto *dm = eve::data::DataModule::create(); + std::string err; + std::unique_ptr doc(dm->decodeJson(text, &err)); + if (!doc || !doc->isObject()) { + if (error) *error = err.empty() ? "invalid json" : err; + return out; + } + auto root = doc->object(); + if (!root) { + if (error) *error = "config root must be object"; + return out; + } + + int mapW = 10, mapH = 10; + float tileW = 32.f, tileH = 32.f; + if (root->has("width")) mapW = asInt(root->get("width"), mapW); + if (root->has("height")) mapH = asInt(root->get("height"), mapH); + if (root->has("tilewidth")) tileW = asFloat(root->get("tilewidth"), tileW); + else if (root->has("tileWidth")) tileW = asFloat(root->get("tileWidth"), tileW); + if (root->has("tileheight")) tileH = asFloat(root->get("tileheight"), tileH); + else if (root->has("tileHeight")) tileH = asFloat(root->get("tileHeight"), tileH); + + TilesetInfo defaultTs; + if (root->has("tilesets")) { + try { + auto arr = root->getArray("tilesets"); + if (arr && arr->size() > 0) defaultTs = readTilesetObject(arr->getObject(0)); + } catch (...) { + } + } else if (root->has("tileset") && !root->isArray("tileset")) { + try { + defaultTs = readTilesetObject(root->getObject("tileset")); + } catch (...) { + } + } else if (root->has("image") || root->has("texture")) { + defaultTs = readTilesetObject(root); + } + + auto bindResource = [&](TileLayer *layer) { + auto res = layer->resource(); + res->path = path; + res->modtime = fileModtime(path); + if (root->has("autoReload")) + res->autoReload = asBool(root->get("autoReload"), true); + fs->watch(path); + if (auto *hot = eve::ModuleManager::getInstance("HotReload")) + hot->bind(path, "tilemap"); + }; + + // Multi-layer document. + if (root->has("layers")) { + try { + auto arr = root->getArray("layers"); + if (arr) { + int sort = 0; + for (size_t i = 0; i < arr->size(); ++i) { + Poco::JSON::Object::Ptr lo; + try { + lo = arr->getObject(i); + } catch (...) { + continue; + } + if (!isTileLayerObject(lo)) continue; + TileLayer *layer = TileLayer::createLayer(mapW, mapH, tileW, tileH); + applyMapGlobals(layer, root); + applyTileset(layer, defaultTs); + applyOneLayerObject(layer, lo, mapW, mapH); + if (!lo->has("layer") && !lo->has("id")) layer->setLayer(sort); + ++sort; + bindResource(layer); + out.push_back(layer); + } + } + } catch (...) { + } + if (!out.empty()) return out; + } + + // Flat single-layer (root.data) or empty map shell. + TileLayer *layer = TileLayer::createLayer(mapW, mapH, tileW, tileH); + applyMapGlobals(layer, root); + applyTileset(layer, defaultTs); + if (root->has("data")) applyFlatLayerData(layer, root); + bindResource(layer); + out.push_back(layer); + return out; +} + +} // namespace eve::map diff --git a/src/modules/map/TileConfig.h b/src/modules/map/TileConfig.h new file mode 100644 index 00000000..753eac96 --- /dev/null +++ b/src/modules/map/TileConfig.h @@ -0,0 +1,37 @@ +#pragma once + +#include "map/TileLayer.h" + +#include +#include + +namespace eve::data { +class JsonDocument; +} + +namespace eve::map { + +/** + * Apply map/layer JSON onto an existing TileLayer (Config + Tiles + Tileset + Draw). + * Multi-layer documents only apply the first orthogonal tile layer when called on one entity. + */ +bool applyConfigDocument(TileLayer *layer, data::JsonDocument *doc); + +/** Parse JSON text and apply onto one layer. */ +bool applyConfigText(TileLayer *layer, const std::string &json, std::string *error = nullptr); + +/** + * Read path via Filesystem, apply onto layer, bind Resource.path + modtime for hot reload. + * If the document has multiple tile layers, only the first is applied to `layer`. + */ +bool loadConfigFile(TileLayer *layer, const std::string &path, std::string *error = nullptr); + +bool reloadConfigFile(TileLayer *layer, std::string *error = nullptr); + +/** + * Load a (possibly multi-layer) map JSON. Creates one TileLayer per tile layer. + * Returns created layers (empty on failure). All share the same Resource.path for reload. + */ +std::vector loadMapFile(const std::string &path, std::string *error = nullptr); + +} // namespace eve::map diff --git a/src/modules/map/TileLayer.cpp b/src/modules/map/TileLayer.cpp new file mode 100644 index 00000000..38da604e --- /dev/null +++ b/src/modules/map/TileLayer.cpp @@ -0,0 +1,115 @@ +#include "map/TileLayer.h" +#include "map/TileConfig.h" + +#include + +namespace eve::map { + +TileLayer *TileLayer::createLayer(int mapW, int mapH, float tileW, float tileH) { + TileLayer *e = TileLayer::create(); + e->config()->entity = e; + e->resize(mapW, mapH); + e->setTileSize(tileW, tileH); + auto ts = e->tileset(); + ts->tileW = int(tileW > 0.f ? tileW : 32.f); + ts->tileH = int(tileH > 0.f ? tileH : 32.f); + (void)e->draw(); + (void)e->resource(); + return e; +} + +void TileLayer::setOrigin(float x, float y) { + config()->originX = x; + config()->originY = y; +} + +float TileLayer::getX() { return config()->originX; } +float TileLayer::getY() { return config()->originY; } + +int TileLayer::getMapWidth() { return config()->mapW; } +int TileLayer::getMapHeight() { return config()->mapH; } +float TileLayer::getTileWidth() { return config()->tileW; } +float TileLayer::getTileHeight() { return config()->tileH; } + +void TileLayer::setTileSize(float tileW, float tileH) { + auto c = config(); + c->tileW = tileW > 0.f ? tileW : 1.f; + c->tileH = tileH > 0.f ? tileH : 1.f; +} + +void TileLayer::resize(int mapW, int mapH) { + auto c = config(); + c->mapW = mapW > 0 ? mapW : 0; + c->mapH = mapH > 0 ? mapH : 0; + const size_t n = size_t(std::max(0, c->mapW) * std::max(0, c->mapH)); + tiles()->gids.assign(n, 0u); +} + +void TileLayer::setTile(int tx, int ty, int gid) { + auto c = config(); + if (tx < 0 || ty < 0 || tx >= c->mapW || ty >= c->mapH) return; + tiles()->gids[size_t(ty * c->mapW + tx)] = uint32_t(gid < 0 ? 0 : gid); +} + +int TileLayer::getTile(int tx, int ty) { + auto c = config(); + if (tx < 0 || ty < 0 || tx >= c->mapW || ty >= c->mapH) return 0; + return int(tiles()->gids[size_t(ty * c->mapW + tx)]); +} + +void TileLayer::fill(int gid) { + const uint32_t g = uint32_t(gid < 0 ? 0 : gid); + for (auto &v : tiles()->gids) v = g; +} + +void TileLayer::clear() { fill(0); } + +void TileLayer::setTileset(graphics::Texture *texture, int firstGid, int columns, int margin, + int spacing) { + auto ts = tileset(); + ts->texture = texture; + ts->firstGid = firstGid > 0 ? firstGid : 1; + ts->columns = columns > 0 ? columns : 1; + ts->margin = margin > 0 ? margin : 0; + ts->spacing = spacing > 0 ? spacing : 0; + if (texture) { + // Default atlas tile size from Config when not set yet. + if (ts->tileW <= 0) ts->tileW = int(config()->tileW); + if (ts->tileH <= 0) ts->tileH = int(config()->tileH); + } +} + +void TileLayer::setTilesetTileSize(int tileW, int tileH) { + auto ts = tileset(); + ts->tileW = tileW > 0 ? tileW : 1; + ts->tileH = tileH > 0 ? tileH : 1; +} + +graphics::Texture *TileLayer::getTilesetTexture() { return tileset()->texture; } +int TileLayer::getTilesetFirstGid() { return tileset()->firstGid; } +int TileLayer::getTilesetColumns() { return tileset()->columns; } + +void TileLayer::setCanvas(graphics::Canvas *canvas) { draw()->canvas = canvas; } +void TileLayer::setCamera(graphics::Camera2D *camera) { draw()->camera = camera; } + +void TileLayer::setLayer(int layer) { draw()->layer = layer; } +int TileLayer::getLayer() { return draw()->layer; } + +void TileLayer::setVisible(bool visible) { draw()->visible = visible; } +bool TileLayer::isVisible() { return draw()->visible; } + +void TileLayer::setTint(float r, float g, float b, float a) { + draw()->tint = Color{r, g, b, a}; +} + +bool TileLayer::applyConfig(const std::string &json) { return applyConfigText(this, json, nullptr); } + +bool TileLayer::loadConfig(const std::string &path) { return loadConfigFile(this, path, nullptr); } + +bool TileLayer::reloadConfig() { return reloadConfigFile(this, nullptr); } + +void TileLayer::setAutoReload(bool enable) { resource()->autoReload = enable; } +bool TileLayer::getAutoReload() { return resource()->autoReload; } +std::string TileLayer::getConfigPath() { return resource()->path; } + +} // namespace eve::map diff --git a/src/modules/map/TileLayer.h b/src/modules/map/TileLayer.h new file mode 100644 index 00000000..8b24ef7c --- /dev/null +++ b/src/modules/map/TileLayer.h @@ -0,0 +1,124 @@ +#pragma once + +#include "common/ECS.h" +#include "graphics/Canvas.h" +#include "graphics/Texture.h" + +#include +#include +#include + +namespace eve::graphics { +class Graphics; +class Camera2D; +class Canvas; +} // namespace eve::graphics + +namespace eve::map { + +/** + * ECS tile layer entity. Script mutates tile GIDs / tileset / draw; TileRenderSystem + * batch-draws atlas quads (or solid debug colors when no texture). + * + * GID 0 = empty. Tiled flip flags (high bits) are masked on read/draw. + */ +class TileLayer : public ecs::Entity { +public: + ENTITY(TileLayer, ecs::Entity) + + void release() override {} + + struct Config { + int mapW = 0; + int mapH = 0; + float tileW = 32.f; + float tileH = 32.f; + float originX = 0.f; + float originY = 0.f; + TileLayer *entity = nullptr; + }; + + struct Tiles { + std::vector gids; + }; + + struct Tileset { + graphics::Texture *texture = nullptr; + int firstGid = 1; + int columns = 1; + int tileW = 32; + int tileH = 32; + int margin = 0; + int spacing = 0; + }; + + struct Draw { + graphics::Canvas *canvas = nullptr; + graphics::Camera2D *camera = nullptr; + int layer = 0; + bool visible = true; + Color tint{1.f, 1.f, 1.f, 1.f}; + }; + + struct Resource { + std::string path; + std::string texturePath; + int64_t modtime = -1; + bool autoReload = true; + }; + + COMPONENT(Config, config) + COMPONENT(Tiles, tiles) + COMPONENT(Tileset, tileset) + COMPONENT(Draw, draw) + COMPONENT(Resource, resource) + + static TileLayer *createLayer(int mapW, int mapH, float tileW = 32.f, float tileH = 32.f); + + void setOrigin(float x, float y); + float getX(); + float getY(); + + int getMapWidth(); + int getMapHeight(); + float getTileWidth(); + float getTileHeight(); + void setTileSize(float tileW, float tileH); + + void resize(int mapW, int mapH); + + void setTile(int tx, int ty, int gid); + int getTile(int tx, int ty); + void fill(int gid); + void clear(); + + void setTileset(graphics::Texture *texture, int firstGid, int columns, int margin = 0, + int spacing = 0); + void setTilesetTileSize(int tileW, int tileH); + graphics::Texture *getTilesetTexture(); + int getTilesetFirstGid(); + int getTilesetColumns(); + + void setCanvas(graphics::Canvas *canvas); + void setCamera(graphics::Camera2D *camera); + + void setLayer(int layer); + int getLayer(); + + void setVisible(bool visible); + bool isVisible(); + + void setTint(float r, float g, float b, float a = 1.f); + + bool applyConfig(const std::string &json); + bool loadConfig(const std::string &path); + bool reloadConfig(); + void setAutoReload(bool enable); + bool getAutoReload(); + std::string getConfigPath(); +}; + +/** Strip Tiled flip / rotate flags; keep low 28 bits. */ +inline uint32_t tileGid(uint32_t raw) { return raw & 0x0FFFFFFFu; } + +} // namespace eve::map diff --git a/src/modules/map/TileSystem.cpp b/src/modules/map/TileSystem.cpp new file mode 100644 index 00000000..aac00f66 --- /dev/null +++ b/src/modules/map/TileSystem.cpp @@ -0,0 +1,179 @@ +#include "map/TileSystem.h" +#include "map/TileLayer.h" +#include "map/TileConfig.h" +#include "graphics/Graphics.h" +#include "graphics/RenderSystem.h" +#include "graphics/Canvas.h" +#include "filesystem/Filesystem.h" +#include "common/Module.h" + +#include +#include + +namespace eve::map { +namespace { + +float clampZoom(float z) { return z <= 0.f ? 1e-4f : z; } + +struct ViewCam { + float x = 0.f; + float y = 0.f; + float zoom = 1.f; + bool valid = false; +}; + +ViewCam fromEntity(graphics::Camera2D *ent) { + ViewCam v; + if (!ent) return v; + auto d = ent->data(); + v.valid = true; + v.x = d->x; + v.y = d->y; + v.zoom = d->zoom; + return v; +} + +void applyCamera(float wx, float wy, float ww, float wh, const ViewCam &cam, int viewW, int viewH, + float &sx, float &sy, float &sw, float &sh) { + if (!cam.valid) { + sx = wx; + sy = wy; + sw = ww; + sh = wh; + return; + } + const float z = clampZoom(cam.zoom); + sx = (wx - cam.x) * z + float(viewW) * 0.5f; + sy = (wy - cam.y) * z + float(viewH) * 0.5f; + sw = ww * z; + sh = wh * z; +} + +/** Deterministic debug color when no tileset texture is bound. */ +Color solidForGid(uint32_t gid, const Color &tint) { + const uint32_t h = gid * 2654435761u; + const float r = ((h >> 0) & 255u) / 255.f; + const float g = ((h >> 8) & 255u) / 255.f; + const float b = ((h >> 16) & 255u) / 255.f; + return Color{r * tint.r, g * tint.g, b * tint.b, tint.a}; +} + +bool atlasUV(const TileLayer::Tileset &ts, uint32_t gid, float &u0, float &v0, float &u1, + float &v1) { + if (!ts.texture || ts.columns <= 0 || ts.tileW <= 0 || ts.tileH <= 0) return false; + if (gid < uint32_t(ts.firstGid)) return false; + const int local = int(gid) - ts.firstGid; + const int col = local % ts.columns; + const int row = local / ts.columns; + const float iw = float(ts.texture->getWidth()); + const float ih = float(ts.texture->getHeight()); + if (iw <= 0.f || ih <= 0.f) return false; + + const float px = float(ts.margin + col * (ts.tileW + ts.spacing)); + const float py = float(ts.margin + row * (ts.tileH + ts.spacing)); + u0 = px / iw; + v0 = py / ih; + u1 = (px + float(ts.tileW)) / iw; + v1 = (py + float(ts.tileH)) / ih; + return true; +} + +void drawLayer(const TileLayer::Config &cfg, const TileLayer::Tiles &tiles, + const TileLayer::Tileset &ts, const TileLayer::Draw &draw, graphics::Graphics *gfx, + const ViewCam &cam) { + if (!gfx || !draw.visible || cfg.mapW <= 0 || cfg.mapH <= 0) return; + if (tiles.gids.size() < size_t(cfg.mapW * cfg.mapH)) return; + + const int viewW = draw.canvas ? draw.canvas->getWidth() : gfx->getWidth(); + const int viewH = draw.canvas ? draw.canvas->getHeight() : gfx->getHeight(); + const Color &tint = draw.tint; + + for (int ty = 0; ty < cfg.mapH; ++ty) { + for (int tx = 0; tx < cfg.mapW; ++tx) { + const uint32_t raw = tiles.gids[size_t(ty * cfg.mapW + tx)]; + const uint32_t gid = tileGid(raw); + if (gid == 0) continue; + + const float wx = cfg.originX + float(tx) * cfg.tileW; + const float wy = cfg.originY + float(ty) * cfg.tileH; + float sx, sy, sw, sh; + applyCamera(wx, wy, cfg.tileW, cfg.tileH, cam, viewW, viewH, sx, sy, sw, sh); + + float u0, v0, u1, v1; + if (atlasUV(ts, gid, u0, v0, u1, v1)) { + gfx->drawTexturedRectUV(ts.texture, sx, sy, sw, sh, u0, v0, u1, v1, tint); + } else { + gfx->drawSolidRect(sx, sy, sw, sh, solidForGid(gid, tint)); + } + } + } +} + +int64_t fileModtime(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + eve::filesystem::Filesystem::Info info{}; + if (!fs->getInfo(path, info)) return -1; + return info.modtime; +} + +} // namespace + +void TileRenderSystem::render(graphics::Graphics *gfx) { + if (!gfx) return; + if (ecs::ComponentManager::inst().registy == nullptr) return; + + struct Item { + TileLayer::Config *cfg; + TileLayer::Tiles *tiles; + TileLayer::Tileset *ts; + TileLayer::Draw *draw; + ViewCam cam; + }; + std::vector items; + + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, tiles, ts, draw] = *it; + if (!draw->visible) continue; + items.push_back(Item{cfg, tiles, ts, draw, fromEntity(draw->camera)}); + } + + std::stable_sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + const bool aOff = a.draw->canvas != nullptr; + const bool bOff = b.draw->canvas != nullptr; + if (aOff != bOff) return aOff && !bOff; + if (a.draw->canvas != b.draw->canvas) return a.draw->canvas < b.draw->canvas; + return a.draw->layer < b.draw->layer; + }); + + graphics::Canvas *current = reinterpret_cast(static_cast(1)); + for (const Item &it : items) { + graphics::Canvas *next = it.draw->canvas; + if (next != current) { + gfx->setCanvas(next); + current = next; + } + drawLayer(*it.cfg, *it.tiles, *it.ts, *it.draw, gfx, it.cam); + } + if (current != nullptr) gfx->setCanvas(); +} + +int TileConfigSystem::poll() { + if (ecs::ComponentManager::inst().registy == nullptr) return 0; + + int reloaded = 0; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, res] = *it; + if (!res->autoReload || res->path.empty() || !cfg->entity) continue; + + const int64_t mt = fileModtime(res->path); + if (mt < 0 || mt == res->modtime) continue; + if (reloadConfigFile(cfg->entity, nullptr)) ++reloaded; + } + return reloaded; +} + +} // namespace eve::map diff --git a/src/modules/map/TileSystem.h b/src/modules/map/TileSystem.h new file mode 100644 index 00000000..d3819dcb --- /dev/null +++ b/src/modules/map/TileSystem.h @@ -0,0 +1,24 @@ +#pragma once + +namespace eve::graphics { +class Graphics; +} + +namespace eve::map { + +/** Draws all visible TileLayer entities via Graphics batch path (UV atlas quads). */ +class TileRenderSystem { +public: + static void render(graphics::Graphics *gfx); +}; + +/** + * Polls bound config files (Resource.path) and hot-reloads when modtime changes. + * Returns number of layers reloaded. + */ +class TileConfigSystem { +public: + static int poll(); +}; + +} // namespace eve::map diff --git a/src/modules/physics/Body.cpp b/src/modules/physics/Body.cpp new file mode 100644 index 00000000..e1ae4618 --- /dev/null +++ b/src/modules/physics/Body.cpp @@ -0,0 +1,236 @@ +#include "physics/Body.h" +#include "physics/Fixture.h" +#include "physics/World.h" + +#include "common/Exception.h" + +#include + +#include + +namespace eve::physics { +namespace { + +b2BodyType parseBodyType(const std::string &type) { + if (type == "static") return b2_staticBody; + if (type == "kinematic") return b2_kinematicBody; + if (type == "dynamic") return b2_dynamicBody; + throw eve::Exception("Body.setType: unknown body type '%s'", type.c_str()); +} + +const char *bodyTypeName(b2BodyType t) { + switch (t) { + case b2_staticBody: return "static"; + case b2_kinematicBody: return "kinematic"; + case b2_dynamicBody: return "dynamic"; + default: return "static"; + } +} + +} // namespace + +Body::Body(World *world, b2Body *body, int id) : world_(world), body_(body), id_(id) {} + +Body::~Body() { + if (body_ && world_ && world_->raw()) { + // Invalidate fixture wrappers before DestroyBody frees b2Fixtures. + b2Fixture *f = body_->GetFixtureList(); + while (f) { + b2Fixture *next = f->GetNext(); + auto *wrap = static_cast(f->GetUserData()); + if (wrap) { + world_->forgetFixture(wrap); + wrap->invalidate(); + } + f = next; + } + body_->SetUserData(nullptr); + world_->raw()->DestroyBody(body_); + world_->forgetBody(this); + } + body_ = nullptr; + world_ = nullptr; +} + +void Body::invalidate() { + if (body_) body_->SetUserData(nullptr); + body_ = nullptr; + world_ = nullptr; +} + +void Body::destroy() { + if (!body_ || !world_ || !world_->raw()) { + invalidate(); + return; + } + b2Fixture *f = body_->GetFixtureList(); + while (f) { + b2Fixture *next = f->GetNext(); + auto *wrap = static_cast(f->GetUserData()); + if (wrap) { + world_->forgetFixture(wrap); + wrap->invalidate(); + } + f = next; + } + body_->SetUserData(nullptr); + world_->raw()->DestroyBody(body_); + world_->forgetBody(this); + body_ = nullptr; + world_ = nullptr; +} + +void Body::setPosition(float x, float y) { + if (!body_ || !world_) return; + body_->SetTransform(b2Vec2(world_->toMeters(x), world_->toMeters(y)), body_->GetAngle()); +} + +float Body::getX() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetPosition().x); +} + +float Body::getY() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetPosition().y); +} + +void Body::setAngle(float radians) { + if (!body_) return; + body_->SetTransform(body_->GetPosition(), radians); +} + +float Body::getAngle() const { + if (!body_) return 0.f; + return body_->GetAngle(); +} + +void Body::setLinearVelocity(float vx, float vy) { + if (!body_ || !world_) return; + body_->SetLinearVelocity(b2Vec2(world_->toMeters(vx), world_->toMeters(vy))); +} + +float Body::getLinearVelocityX() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetLinearVelocity().x); +} + +float Body::getLinearVelocityY() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetLinearVelocity().y); +} + +void Body::setAngularVelocity(float omega) { + if (!body_) return; + body_->SetAngularVelocity(omega); +} + +float Body::getAngularVelocity() const { + if (!body_) return 0.f; + return body_->GetAngularVelocity(); +} + +void Body::applyForce(float fx, float fy) { + if (!body_ || !world_) return; + // Force in Newtons ≈ (pixels/s² * mass) with mass in kg; convert like LÖVE. + body_->ApplyForceToCenter(b2Vec2(world_->toMeters(fx), world_->toMeters(fy)), true); +} + +void Body::applyForceAt(float fx, float fy, float x, float y) { + if (!body_ || !world_) return; + body_->ApplyForce(b2Vec2(world_->toMeters(fx), world_->toMeters(fy)), + b2Vec2(world_->toMeters(x), world_->toMeters(y)), true); +} + +void Body::applyLinearImpulse(float ix, float iy) { + if (!body_ || !world_) return; + body_->ApplyLinearImpulse(b2Vec2(world_->toMeters(ix), world_->toMeters(iy)), + body_->GetWorldCenter(), true); +} + +void Body::applyAngularImpulse(float impulse) { + if (!body_) return; + body_->ApplyAngularImpulse(impulse, true); +} + +void Body::setType(const std::string &bodyType) { + if (!body_) return; + body_->SetType(parseBodyType(bodyType)); +} + +std::string Body::getType() const { + if (!body_) return "static"; + return bodyTypeName(body_->GetType()); +} + +void Body::setFixedRotation(bool fixed) { + if (!body_) return; + body_->SetFixedRotation(fixed); +} + +bool Body::isFixedRotation() const { return body_ ? body_->IsFixedRotation() : false; } + +void Body::setActive(bool active) { + if (!body_) return; + body_->SetActive(active); +} + +bool Body::isActive() const { return body_ ? body_->IsActive() : false; } + +void Body::setBullet(bool bullet) { + if (!body_) return; + body_->SetBullet(bullet); +} + +bool Body::isBullet() const { return body_ ? body_->IsBullet() : false; } + +void Body::setAwake(bool awake) { + if (!body_) return; + body_->SetAwake(awake); +} + +bool Body::isAwake() const { return body_ ? body_->IsAwake() : false; } + +Fixture *Body::newRectangleFixture(float width, float height, float density, float friction, + float restitution) { + if (!body_ || !world_) throw eve::Exception("Body.newRectangleFixture: body destroyed"); + if (width <= 0.f || height <= 0.f) + throw eve::Exception("Body.newRectangleFixture: width/height must be > 0"); + + b2PolygonShape shape; + shape.SetAsBox(world_->toMeters(width) * 0.5f, world_->toMeters(height) * 0.5f); + + b2FixtureDef def; + def.shape = &shape; + def.density = density; + def.friction = friction; + def.restitution = restitution; + + b2Fixture *raw = body_->CreateFixture(&def); + auto *fx = new Fixture(world_, this, raw); + raw->SetUserData(fx); + world_->fixtures_.insert(fx); + return fx; +} + +Fixture *Body::newCircleFixture(float radius, float density, float friction, float restitution) { + if (!body_ || !world_) throw eve::Exception("Body.newCircleFixture: body destroyed"); + if (radius <= 0.f) throw eve::Exception("Body.newCircleFixture: radius must be > 0"); + + b2CircleShape shape; + shape.m_radius = world_->toMeters(radius); + + b2FixtureDef def; + def.shape = &shape; + def.density = density; + def.friction = friction; + def.restitution = restitution; + + b2Fixture *raw = body_->CreateFixture(&def); + auto *fx = new Fixture(world_, this, raw); + raw->SetUserData(fx); + world_->fixtures_.insert(fx); + return fx; +} + +} // namespace eve::physics diff --git a/src/modules/physics/Body.h b/src/modules/physics/Body.h new file mode 100644 index 00000000..e7268c4b --- /dev/null +++ b/src/modules/physics/Body.h @@ -0,0 +1,79 @@ +#pragma once + +#include + +class b2Body; + +namespace eve::physics { + +class World; +class Fixture; + +class Body { +public: + Body(World *world, b2Body *body, int id); + ~Body(); + + Body(const Body &) = delete; + Body &operator=(const Body &) = delete; + + int getId() const { return id_; } + + void setPosition(float x, float y); + float getX() const; + float getY() const; + + void setAngle(float radians); + float getAngle() const; + + void setLinearVelocity(float vx, float vy); + float getLinearVelocityX() const; + float getLinearVelocityY() const; + + void setAngularVelocity(float omega); + float getAngularVelocity() const; + + void applyForce(float fx, float fy); + void applyForceAt(float fx, float fy, float x, float y); + void applyLinearImpulse(float ix, float iy); + void applyAngularImpulse(float impulse); + + void setType(const std::string &bodyType); + std::string getType() const; + + void setFixedRotation(bool fixed); + bool isFixedRotation() const; + + void setActive(bool active); + bool isActive() const; + + void setBullet(bool bullet); + bool isBullet() const; + + void setAwake(bool awake); + bool isAwake() const; + + /** width/height in pixels; density kg/m² (Box2D units). */ + Fixture *newRectangleFixture(float width, float height, float density = 1.f, + float friction = 0.2f, float restitution = 0.f); + Fixture *newCircleFixture(float radius, float density = 1.f, float friction = 0.2f, + float restitution = 0.f); + + void destroy(); + + World *getWorld() { return world_; } + b2Body *raw() { return body_; } + const b2Body *raw() const { return body_; } + + void invalidate(); + +private: + friend class World; + friend class Fixture; + + World *world_ = nullptr; + b2Body *body_ = nullptr; + int id_ = 0; +}; + +} // namespace eve::physics diff --git a/src/modules/physics/Fixture.cpp b/src/modules/physics/Fixture.cpp new file mode 100644 index 00000000..4b366874 --- /dev/null +++ b/src/modules/physics/Fixture.cpp @@ -0,0 +1,72 @@ +#include "physics/Fixture.h" +#include "physics/Body.h" +#include "physics/World.h" + +#include + +namespace eve::physics { + +Fixture::Fixture(World *world, Body *body, b2Fixture *fixture) + : world_(world), body_(body), fixture_(fixture) {} + +Fixture::~Fixture() { + if (fixture_ && body_ && body_->raw()) { + fixture_->SetUserData(nullptr); + body_->raw()->DestroyFixture(fixture_); + if (world_) world_->forgetFixture(this); + } + fixture_ = nullptr; + body_ = nullptr; + world_ = nullptr; +} + +void Fixture::invalidate() { + if (fixture_) fixture_->SetUserData(nullptr); + fixture_ = nullptr; + body_ = nullptr; + world_ = nullptr; +} + +void Fixture::destroy() { + if (!fixture_ || !body_ || !body_->raw()) { + invalidate(); + return; + } + fixture_->SetUserData(nullptr); + body_->raw()->DestroyFixture(fixture_); + if (world_) world_->forgetFixture(this); + fixture_ = nullptr; + body_ = nullptr; + world_ = nullptr; +} + +void Fixture::setSensor(bool sensor) { + if (!fixture_) return; + fixture_->SetSensor(sensor); +} + +bool Fixture::isSensor() const { return fixture_ ? fixture_->IsSensor() : false; } + +void Fixture::setFriction(float friction) { + if (!fixture_) return; + fixture_->SetFriction(friction); +} + +float Fixture::getFriction() const { return fixture_ ? fixture_->GetFriction() : 0.f; } + +void Fixture::setRestitution(float restitution) { + if (!fixture_) return; + fixture_->SetRestitution(restitution); +} + +float Fixture::getRestitution() const { return fixture_ ? fixture_->GetRestitution() : 0.f; } + +void Fixture::setDensity(float density) { + if (!fixture_) return; + fixture_->SetDensity(density); + if (body_ && body_->raw()) body_->raw()->ResetMassData(); +} + +float Fixture::getDensity() const { return fixture_ ? fixture_->GetDensity() : 0.f; } + +} // namespace eve::physics diff --git a/src/modules/physics/Fixture.h b/src/modules/physics/Fixture.h new file mode 100644 index 00000000..28dd5100 --- /dev/null +++ b/src/modules/physics/Fixture.h @@ -0,0 +1,48 @@ +#pragma once + +class b2Fixture; + +namespace eve::physics { + +class Body; +class World; + +class Fixture { +public: + Fixture(World *world, Body *body, b2Fixture *fixture); + ~Fixture(); + + Fixture(const Fixture &) = delete; + Fixture &operator=(const Fixture &) = delete; + + void setSensor(bool sensor); + bool isSensor() const; + + void setFriction(float friction); + float getFriction() const; + + void setRestitution(float restitution); + float getRestitution() const; + + void setDensity(float density); + float getDensity() const; + + Body *getBody() { return body_; } + + void destroy(); + + b2Fixture *raw() { return fixture_; } + const b2Fixture *raw() const { return fixture_; } + + void invalidate(); + +private: + friend class World; + friend class Body; + + World *world_ = nullptr; + Body *body_ = nullptr; + b2Fixture *fixture_ = nullptr; +}; + +} // namespace eve::physics diff --git a/src/modules/physics/Physics.cpp b/src/modules/physics/Physics.cpp new file mode 100644 index 00000000..1b07488c --- /dev/null +++ b/src/modules/physics/Physics.cpp @@ -0,0 +1,96 @@ +#include "physics/Physics.h" +#include "physics/World.h" +#include "physics/Body.h" +#include "physics/Fixture.h" + +#include "common/Exception.h" + +#include + +namespace eve::physics { + +Module_IMPL(Physics, new Physics()); + +void Physics::setMeter(float pixelsPerMeter) { + if (pixelsPerMeter <= 0.f) + throw Exception("Physics.setMeter: pixelsPerMeter must be > 0"); + meter_ = pixelsPerMeter; +} + +float Physics::getMeter() const { return meter_; } + +World *Physics::newWorld(float gravityX, float gravityY, bool sleep) { + return new World(gravityX, gravityY, sleep, meter_); +} + +void Physics::expose(ssq::Table &table) { + auto cls = table.addClass(name, Physics::create, false); + expose(cls); + + auto world = table.addClass( + "World", std::function([]() -> World * { return nullptr; }), true); + world.addFunc("update", &World::update); + world.addFunc("updateFull", &World::updateFull); + world.addFunc("setGravity", &World::setGravity); + world.addFunc("getGravityX", &World::getGravityX); + world.addFunc("getGravityY", &World::getGravityY); + world.addFunc("setMeter", &World::setMeter); + world.addFunc("getMeter", &World::getMeter); + world.addFunc("newBody", &World::newBody); + world.addFunc("destroyBody", &World::destroyBody); + world.addFunc("destroy", &World::destroy); + world.addFunc("drawDebug", &World::drawDebug); + + auto body = table.addClass( + "Body", std::function([]() -> Body * { return nullptr; }), true); + body.addFunc("getId", &Body::getId); + body.addFunc("setPosition", &Body::setPosition); + body.addFunc("getX", &Body::getX); + body.addFunc("getY", &Body::getY); + body.addFunc("setAngle", &Body::setAngle); + body.addFunc("getAngle", &Body::getAngle); + body.addFunc("setLinearVelocity", &Body::setLinearVelocity); + body.addFunc("getLinearVelocityX", &Body::getLinearVelocityX); + body.addFunc("getLinearVelocityY", &Body::getLinearVelocityY); + body.addFunc("setAngularVelocity", &Body::setAngularVelocity); + body.addFunc("getAngularVelocity", &Body::getAngularVelocity); + body.addFunc("applyForce", &Body::applyForce); + body.addFunc("applyForceAt", &Body::applyForceAt); + body.addFunc("applyLinearImpulse", &Body::applyLinearImpulse); + body.addFunc("applyAngularImpulse", &Body::applyAngularImpulse); + body.addFunc("setType", &Body::setType); + body.addFunc("getType", &Body::getType); + body.addFunc("setFixedRotation", &Body::setFixedRotation); + body.addFunc("isFixedRotation", &Body::isFixedRotation); + body.addFunc("setActive", &Body::setActive); + body.addFunc("isActive", &Body::isActive); + body.addFunc("setBullet", &Body::setBullet); + body.addFunc("isBullet", &Body::isBullet); + body.addFunc("setAwake", &Body::setAwake); + body.addFunc("isAwake", &Body::isAwake); + body.addFunc("newRectangleFixture", &Body::newRectangleFixture); + body.addFunc("newCircleFixture", &Body::newCircleFixture); + body.addFunc("destroy", &Body::destroy); + + auto fixture = table.addClass( + "Fixture", std::function([]() -> Fixture * { return nullptr; }), true); + fixture.addFunc("setSensor", &Fixture::setSensor); + fixture.addFunc("isSensor", &Fixture::isSensor); + fixture.addFunc("setFriction", &Fixture::setFriction); + fixture.addFunc("getFriction", &Fixture::getFriction); + fixture.addFunc("setRestitution", &Fixture::setRestitution); + fixture.addFunc("getRestitution", &Fixture::getRestitution); + fixture.addFunc("setDensity", &Fixture::setDensity); + fixture.addFunc("getDensity", &Fixture::getDensity); + fixture.addFunc("getBody", &Fixture::getBody); + fixture.addFunc("destroy", &Fixture::destroy); +} + +void Physics::expose(ssq::Class &cls) { + cls.addFunc("getName", &Physics::getName); + cls.addFunc("setMeter", &Physics::setMeter); + cls.addFunc("getMeter", &Physics::getMeter); + cls.addFunc("newWorld", &Physics::newWorld); +} + +} // namespace eve::physics diff --git a/src/modules/physics/Physics.h b/src/modules/physics/Physics.h new file mode 100644 index 00000000..46c2fc0c --- /dev/null +++ b/src/modules/physics/Physics.h @@ -0,0 +1,35 @@ +#pragma once + +#include "common/Module.h" + +namespace eve::physics { + +class World; + +/** + * Box2D physics module — factory + meter scale (pixels per meter). + * Script: `physics <- eve.Physics(); world <- physics.newWorld(0, 900);` + */ +class Physics : public Module { +public: + Module_REG(Physics); + Physics() = default; + ~Physics() override = default; + + /** Pixels per meter (default 30, same as LÖVE). */ + void setMeter(float pixelsPerMeter); + float getMeter() const; + + /** + * Create a physics world. + * @param gravityX gravity X in pixels/s² + * @param gravityY gravity Y in pixels/s² + * @param sleep allow sleeping bodies + */ + World *newWorld(float gravityX, float gravityY, bool sleep = true); + +private: + float meter_ = 30.f; +}; + +} // namespace eve::physics diff --git a/src/modules/physics/World.cpp b/src/modules/physics/World.cpp new file mode 100644 index 00000000..d3a043da --- /dev/null +++ b/src/modules/physics/World.cpp @@ -0,0 +1,254 @@ +#include "physics/World.h" +#include "physics/Body.h" +#include "physics/Fixture.h" + +#include "common/Exception.h" +#include "event/Event.h" +#include "graphics/Graphics.h" +#include "graphics/Canvas.h" + +#include + +#include +#include +#include + +namespace eve::physics { +namespace { + +b2BodyType parseBodyType(const std::string &type) { + if (type == "static") return b2_staticBody; + if (type == "kinematic") return b2_kinematicBody; + if (type == "dynamic") return b2_dynamicBody; + throw eve::Exception("World.newBody: unknown body type '%s' (use static|kinematic|dynamic)", + type.c_str()); +} + +Body *bodyFromFixture(b2Fixture *f) { + if (!f) return nullptr; + b2Body *b = f->GetBody(); + if (!b) return nullptr; + return static_cast(b->GetUserData()); +} + +} // namespace + +class ContactRelay : public b2ContactListener { +public: + explicit ContactRelay(World *world) : world_(world) {} + + void BeginContact(b2Contact *contact) override { world_->onBeginContact(contact); } + void EndContact(b2Contact *contact) override { world_->onEndContact(contact); } + +private: + World *world_; +}; + +class DebugDraw : public b2Draw { +public: + DebugDraw() { SetFlags(e_shapeBit | e_jointBit | e_aabbBit); } + + void begin(graphics::Graphics *gfx, float meter) { + gfx_ = gfx; + meter_ = meter; + } + void end() { gfx_ = nullptr; } + + void DrawPolygon(const b2Vec2 *vertices, int32 vertexCount, const b2Color &color) override { + drawPoly(vertices, vertexCount, color, false); + } + void DrawSolidPolygon(const b2Vec2 *vertices, int32 vertexCount, const b2Color &color) override { + drawPoly(vertices, vertexCount, color, true); + } + void DrawCircle(const b2Vec2 ¢er, float32 radius, const b2Color &color) override { + drawCircle(center, radius, color, false); + } + void DrawSolidCircle(const b2Vec2 ¢er, float32 radius, const b2Vec2 & /*axis*/, + const b2Color &color) override { + drawCircle(center, radius, color, true); + } + void DrawSegment(const b2Vec2 &p1, const b2Vec2 &p2, const b2Color &color) override { + if (!gfx_) return; + float x1 = p1.x * meter_, y1 = p1.y * meter_; + float x2 = p2.x * meter_, y2 = p2.y * meter_; + float minx = std::min(x1, x2), miny = std::min(y1, y2); + float w = std::max(1.f, std::fabs(x2 - x1)); + float h = std::max(1.f, std::fabs(y2 - y1)); + gfx_->drawSolidRect(minx, miny, w, h, Color(color.r, color.g, color.b, color.a * 0.8f)); + } + void DrawTransform(const b2Transform &xf) override { + DrawSegment(xf.p, xf.p + 0.5f * xf.q.GetXAxis(), b2Color(1, 0, 0)); + DrawSegment(xf.p, xf.p + 0.5f * xf.q.GetYAxis(), b2Color(0, 1, 0)); + } + +private: + void drawPoly(const b2Vec2 *vertices, int32 vertexCount, const b2Color &color, bool solid) { + if (!gfx_ || vertexCount < 2) return; + for (int32 i = 0; i < vertexCount; ++i) { + const b2Vec2 &a = vertices[i]; + const b2Vec2 &b = vertices[(i + 1) % vertexCount]; + DrawSegment(a, b, color); + } + if (solid && vertexCount >= 3) { + float minx = vertices[0].x, maxx = vertices[0].x; + float miny = vertices[0].y, maxy = vertices[0].y; + for (int32 i = 1; i < vertexCount; ++i) { + minx = std::min(minx, vertices[i].x); + maxx = std::max(maxx, vertices[i].x); + miny = std::min(miny, vertices[i].y); + maxy = std::max(maxy, vertices[i].y); + } + gfx_->drawSolidRect(minx * meter_, miny * meter_, (maxx - minx) * meter_, + (maxy - miny) * meter_, + Color(color.r, color.g, color.b, color.a * 0.25f)); + } + } + void drawCircle(const b2Vec2 ¢er, float32 radius, const b2Color &color, bool solid) { + if (!gfx_) return; + float px = (center.x - radius) * meter_; + float py = (center.y - radius) * meter_; + float d = radius * 2.f * meter_; + float a = solid ? color.a * 0.35f : color.a * 0.7f; + gfx_->drawSolidRect(px, py, d, d, Color(color.r, color.g, color.b, a)); + } + + graphics::Graphics *gfx_ = nullptr; + float meter_ = 30.f; +}; + +World::World(float gravityX, float gravityY, bool sleep, float meter) : meter_(meter) { + if (meter_ <= 0.f) meter_ = 30.f; + world_ = new b2World(b2Vec2(toMeters(gravityX), toMeters(gravityY))); + world_->SetAllowSleeping(sleep); + relay_ = new ContactRelay(this); + world_->SetContactListener(relay_); + draw_ = new DebugDraw(); + world_->SetDebugDraw(draw_); +} + +World::~World() { destroy(); } + +void World::destroy() { + if (destroyed_) return; + destroyed_ = true; + + // Copy sets — Body/Fixture destructors erase from them. + std::vector bodies(bodies_.begin(), bodies_.end()); + for (Body *b : bodies) { + if (b) { + b->invalidate(); + // Script may still hold Body*; leave object but null raw pointer. + // If World is script-owned and Body is also script-owned, Body::~Body + // will see null body_ and skip DestroyBody. + } + } + bodies_.clear(); + + std::vector fixtures(fixtures_.begin(), fixtures_.end()); + for (Fixture *f : fixtures) { + if (f) f->invalidate(); + } + fixtures_.clear(); + + if (world_) { + world_->SetContactListener(nullptr); + world_->SetDebugDraw(nullptr); + delete world_; + world_ = nullptr; + } + delete relay_; + relay_ = nullptr; + delete draw_; + draw_ = nullptr; +} + +void World::update(float dt) { updateFull(dt, 8, 3); } + +void World::updateFull(float dt, int velocityIterations, int positionIterations) { + if (!world_ || destroyed_) return; + if (dt < 0.f) dt = 0.f; + // Cap to avoid spiral-of-death on hitch frames. + if (dt > 0.05f) dt = 0.05f; + world_->Step(dt, velocityIterations, positionIterations); +} + +void World::setGravity(float gx, float gy) { + if (!world_) return; + world_->SetGravity(b2Vec2(toMeters(gx), toMeters(gy))); +} + +float World::getGravityX() const { + if (!world_) return 0.f; + return toPixels(world_->GetGravity().x); +} + +float World::getGravityY() const { + if (!world_) return 0.f; + return toPixels(world_->GetGravity().y); +} + +void World::setMeter(float pixelsPerMeter) { + if (pixelsPerMeter <= 0.f) + throw eve::Exception("World.setMeter: pixelsPerMeter must be > 0"); + meter_ = pixelsPerMeter; +} + +float World::toMeters(float pixels) const { return pixels / meter_; } +float World::toPixels(float meters) const { return meters * meter_; } + +int World::nextBodyId() { return nextId_++; } + +Body *World::newBody(const std::string &bodyType, float x, float y) { + if (!world_ || destroyed_) throw eve::Exception("World.newBody: world destroyed"); + + b2BodyDef def; + def.type = parseBodyType(bodyType); + def.position = b2Vec2(toMeters(x), toMeters(y)); + + b2Body *raw = world_->CreateBody(&def); + Body *body = new Body(this, raw, nextBodyId()); + raw->SetUserData(body); + bodies_.insert(body); + return body; +} + +void World::destroyBody(Body *body) { + if (!body) return; + body->destroy(); +} + +void World::forgetBody(Body *body) { bodies_.erase(body); } +void World::forgetFixture(Fixture *fixture) { fixtures_.erase(fixture); } + +void World::onBeginContact(b2Contact *contact) { + Body *a = bodyFromFixture(contact->GetFixtureA()); + Body *b = bodyFromFixture(contact->GetFixtureB()); + if (!a || !b) return; + + auto *ev = eve::ModuleManager::getInstance("Event"); + if (!ev) return; + std::vector args = {eve::event::Variant::makeInt(a->getId()), + eve::event::Variant::makeInt(b->getId())}; + ev->push(new eve::event::Message("begincontact", args)); +} + +void World::onEndContact(b2Contact *contact) { + Body *a = bodyFromFixture(contact->GetFixtureA()); + Body *b = bodyFromFixture(contact->GetFixtureB()); + if (!a || !b) return; + + auto *ev = eve::ModuleManager::getInstance("Event"); + if (!ev) return; + std::vector args = {eve::event::Variant::makeInt(a->getId()), + eve::event::Variant::makeInt(b->getId())}; + ev->push(new eve::event::Message("endcontact", args)); +} + +void World::drawDebug(graphics::Graphics *gfx) { + if (!world_ || !draw_ || !gfx) return; + draw_->begin(gfx, meter_); + world_->DrawDebugData(); + draw_->end(); +} + +} // namespace eve::physics diff --git a/src/modules/physics/World.h b/src/modules/physics/World.h new file mode 100644 index 00000000..4a9c9ad6 --- /dev/null +++ b/src/modules/physics/World.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include + +class b2World; +class b2Body; +class b2Fixture; +class b2Contact; + +namespace eve::graphics { +class Graphics; +} + +namespace eve::physics { + +class Body; +class Fixture; +class ContactRelay; +class DebugDraw; + +class World { +public: + World(float gravityX, float gravityY, bool sleep, float meter); + ~World(); + + World(const World &) = delete; + World &operator=(const World &) = delete; + + void update(float dt); + void updateFull(float dt, int velocityIterations, int positionIterations); + + void setGravity(float gx, float gy); + float getGravityX() const; + float getGravityY() const; + + void setMeter(float pixelsPerMeter); + float getMeter() const { return meter_; } + + /** bodyType: "static" | "kinematic" | "dynamic". x/y in pixels. */ + Body *newBody(const std::string &bodyType, float x, float y); + + void destroyBody(Body *body); + void destroy(); + + /** Optional: draw fixture AABBs via Graphics::drawSolidRect. */ + void drawDebug(graphics::Graphics *gfx); + + float toMeters(float pixels) const; + float toPixels(float meters) const; + + b2World *raw() { return world_; } + const b2World *raw() const { return world_; } + + void onBeginContact(b2Contact *contact); + void onEndContact(b2Contact *contact); + + void forgetBody(Body *body); + void forgetFixture(Fixture *fixture); + + int nextBodyId(); + +private: + friend class Body; + friend class Fixture; + + b2World *world_ = nullptr; + ContactRelay *relay_ = nullptr; + DebugDraw *draw_ = nullptr; + float meter_ = 30.f; + int nextId_ = 1; + bool destroyed_ = false; + + std::unordered_set bodies_; + std::unordered_set fixtures_; +}; + +} // namespace eve::physics diff --git a/src/scripts/load.nut b/src/scripts/load.nut index fd9cdfc5..f99a4a37 100644 --- a/src/scripts/load.nut +++ b/src/scripts/load.nut @@ -56,12 +56,15 @@ event <- eve.Event(); timer <- eve.Timer(); ui <- eve.UI(); particles <- eve.Particles(); +map <- eve.Map(); +physics <- eve.Physics(); keyboard <- eve.Keyboard(); mouse <- eve.Mouse(); touch <- eve.Touch(); sound <- eve.Sound(); audio <- eve.Audio(); model3d <- eve.Model3D(); +font <- eve.Font(); fs <- eve.Filesystem(); hot <- eve.HotReload(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6fd2547f..5f1ea0d0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -22,12 +22,15 @@ file(GLOB all_test_cpp UIPhaseAdv.cpp UIOverlaySmoke.cpp particles.cpp + map.cpp + box2d.cpp hotreload.cpp shader.cpp simplesquirrel.cpp sound.cpp model3d.cpp audio.cpp + font.cpp window.cpp window_cpp.cpp image.cpp @@ -92,19 +95,19 @@ endif() if(WIN32) if(${CMAKE_BUILD_TYPE} STREQUAL "Debug") - set(ThirdParty ${ThirdParty} physfsmdd medialoader_imagemdd medialoader_modelmdd medialoader_soundmdd assimpmdd zlibstaticd lz4mdd - vorbis-staticmdd vorbisfile-staticmdd ogg-staticmdd modplug-staticmdd mpg123mdd OpenAL32mdd) + set(ThirdParty ${ThirdParty} physfsmdd medialoader_imagemdd medialoader_modelmdd medialoader_soundmdd assimpmdd zlibstaticd lz4mdd Box2D + vorbis-staticmdd vorbisfile-staticmdd ogg-staticmdd modplug-staticmdd mpg123mdd OpenAL32mdd freetype) else() - set(ThirdParty ${ThirdParty} physfsmd medialoader_imagemd medialoader_modelmd medialoader_soundmd assimpmd zlibstatic lz4md - vorbis-staticmd vorbisfile-staticmd ogg-staticmd modplug-staticmd mpg123md OpenAL32md) + set(ThirdParty ${ThirdParty} physfsmd medialoader_imagemd medialoader_modelmd medialoader_soundmd assimpmd zlibstatic lz4md Box2D + vorbis-staticmd vorbisfile-staticmd ogg-staticmd modplug-staticmd mpg123md OpenAL32md freetype) endif() else() if(APPLE) - set(ThirdParty ${ThirdParty} physfs medialoader_image medialoader_model medialoader_sound assimp z lz4 - vorbis-static vorbisfile-static ogg-static modplug-static mpg123 openal) + set(ThirdParty ${ThirdParty} physfs medialoader_image medialoader_model medialoader_sound assimp z lz4 Box2D + vorbis-static vorbisfile-static ogg-static modplug-static mpg123 openal freetype) else() - set(ThirdParty ${ThirdParty} physfs medialoader_image medialoader_model medialoader_sound assimp z lz4 - vorbis vorbisfile modplug mpg123 OpenAL) + set(ThirdParty ${ThirdParty} physfs medialoader_image medialoader_model medialoader_sound assimp z lz4 Box2D + vorbis vorbisfile modplug mpg123 OpenAL freetype) endif() endif() @@ -124,7 +127,7 @@ else() endif() endif() -set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVModel3D) +set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles EVPhysics EVMap EVModel3D EVFont) if(EVENGINE_BUILD_DEMO) list(APPEND EVELIBS EVDemo) diff --git a/test/box2d.cpp b/test/box2d.cpp new file mode 100644 index 00000000..16f4cdd5 --- /dev/null +++ b/test/box2d.cpp @@ -0,0 +1,109 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "physics/Physics.h" +#include "physics/World.h" +#include "physics/Body.h" +#include "physics/Fixture.h" + +#include +#include + +using namespace eve::physics; + +TEST_CASE("box2d.module.meter") { + auto *mod = Physics::create(); + CHECK_EQ(mod->getMeter(), 30.f); + mod->setMeter(50.f); + CHECK_EQ(mod->getMeter(), 50.f); + mod->setMeter(30.f); +} + +TEST_CASE("box2d.world.createAndGravity") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 900.f, true)); + REQUIRE(world.get() != nullptr); + CHECK(std::fabs(world->getGravityX()) < 0.01f); + CHECK(std::fabs(world->getGravityY() - 900.f) < 0.01f); + world->setGravity(10.f, 20.f); + CHECK(std::fabs(world->getGravityX() - 10.f) < 0.01f); + CHECK(std::fabs(world->getGravityY() - 20.f) < 0.01f); +} + +TEST_CASE("box2d.body.fallsUnderGravity") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 900.f, true)); + Body *ground = world->newBody("static", 400.f, 500.f); + ground->newRectangleFixture(800.f, 40.f, 0.f, 0.5f, 0.f); + + Body *box = world->newBody("dynamic", 400.f, 100.f); + box->newRectangleFixture(40.f, 40.f, 1.f, 0.3f, 0.1f); + float y0 = box->getY(); + + for (int i = 0; i < 60; ++i) + world->update(1.f / 60.f); + + CHECK_GT(box->getY(), y0); + CHECK_LT(box->getY(), 500.f); +} + +TEST_CASE("box2d.body.staticDoesNotMove") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 900.f, true)); + Body *ground = world->newBody("static", 200.f, 300.f); + ground->newRectangleFixture(100.f, 20.f, 0.f); + float y0 = ground->getY(); + for (int i = 0; i < 30; ++i) + world->update(1.f / 60.f); + CHECK(std::fabs(ground->getY() - y0) < 0.01f); + CHECK_EQ(ground->getType(), std::string("static")); +} + +TEST_CASE("box2d.body.circleAndVelocity") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *ball = world->newBody("dynamic", 0.f, 0.f); + ball->newCircleFixture(16.f, 1.f, 0.2f, 0.8f); + ball->setLinearVelocity(300.f, 0.f); + + for (int i = 0; i < 30; ++i) + world->update(1.f / 60.f); + + CHECK_GT(ball->getX(), 100.f); + CHECK(std::fabs(ball->getY()) < 5.f); +} + +TEST_CASE("box2d.fixture.sensor") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *b = world->newBody("dynamic", 0.f, 0.f); + Fixture *fixture = b->newRectangleFixture(10.f, 10.f); + CHECK(!fixture->isSensor()); + fixture->setSensor(true); + CHECK(fixture->isSensor()); + fixture->setFriction(0.7f); + CHECK(std::fabs(fixture->getFriction() - 0.7f) < 0.001f); +} + +TEST_CASE("box2d.body.destroy") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *b = world->newBody("dynamic", 10.f, 20.f); + b->newRectangleFixture(8.f, 8.f); + int id = b->getId(); + CHECK_GT(id, 0); + b->destroy(); + CHECK(std::fabs(b->getX()) < 0.001f); + delete b; +} + +TEST_CASE("box2d.world.destroyInvalidates") { + auto *mod = Physics::create(); + World *world = mod->newWorld(0.f, 100.f, true); + Body *b = world->newBody("dynamic", 50.f, 50.f); + b->newCircleFixture(5.f); + world->destroy(); + CHECK(std::fabs(b->getX()) < 0.001f); + delete b; + delete world; +} diff --git a/test/font.cpp b/test/font.cpp new file mode 100644 index 00000000..7a148e37 --- /dev/null +++ b/test/font.cpp @@ -0,0 +1,102 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Exception.h" +#include "data/ByteData.h" +#include "font/Font.h" +#include "font/FontData.h" +#include "image/ImageData.h" + +#include +#include +#include +#include +#include + +namespace { + +// FontAwesome private-use icon with a non-empty bitmap in the fixture. +constexpr int kIconCodepoint = 0xF000; + +std::string pathBesideThisSource(const char *filename) { + std::string here = __FILE__; + auto slash = here.find_last_of("/\\"); + std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); + return dir + "/" + filename; +} + +std::vector readBinaryFile(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in) + return {}; + return std::vector((std::istreambuf_iterator(in)), std::istreambuf_iterator()); +} + +bool expectException(const std::function &fn) { + try { + fn(); + } catch (const eve::Exception &) { + return true; + } + return false; +} + +eve::font::Font *fontModule() { return eve::font::Font::create(); } + +std::unique_ptr loadFixture(int size = 24) { + auto raw = readBinaryFile(pathBesideThisSource("fonts/FontAwesome.ttf")); + if (raw.empty()) + return nullptr; + eve::data::ByteData data(raw.data(), raw.size()); + return std::unique_ptr(fontModule()->newFontData(&data, size)); +} + +} // namespace + +TEST_CASE("font.create") { + auto *module = fontModule(); + REQUIRE(module != nullptr); + CHECK_EQ(module->getName(), std::string("Font")); +} + +TEST_CASE("font.newFontData.empty") { + auto *module = fontModule(); + CHECK(expectException([&] { module->newFontData(nullptr); })); + CHECK(expectException([&] { module->newFontDataFromFile(""); })); +} + +TEST_CASE("font.newFontData.garbage") { + auto *module = fontModule(); + const char garbage[] = "this is not a font"; + eve::data::ByteData bytes(garbage, sizeof(garbage) - 1); + CHECK(expectException([&] { module->newFontData(&bytes, 16); })); +} + +TEST_CASE("font.newFontData.ttf") { + auto fd = loadFixture(24); + REQUIRE(fd.get() != nullptr); + CHECK_EQ(fd->getSize(), 24); + CHECK(fd->getAscent() > 0.f); + CHECK(fd->getLineHeight() > 0.f); + CHECK(fd->getGlyphCount() > 0); + CHECK(!fd->getFamilyName().empty()); + CHECK(fd->hasGlyph(kIconCodepoint)); + CHECK(!fd->hasGlyph(static_cast(0x4E00))); // CJK not in fixture + CHECK(fd->getWidth("\xEF\x80\x80") > 0.f); // UTF-8 for U+F000 + CHECK(fd->getGlyphAdvance(kIconCodepoint) > 0); + + std::unique_ptr glyph(fd->newGlyphImageData(kIconCodepoint)); + REQUIRE(glyph.get() != nullptr); + CHECK(glyph->getWidth() > 0); + CHECK(glyph->getHeight() > 0); + CHECK_EQ(glyph->getFormat(), std::string("RGBA8")); +} + +TEST_CASE("font.newFontData.invalidSize") { + auto raw = readBinaryFile(pathBesideThisSource("fonts/FontAwesome.ttf")); + REQUIRE(!raw.empty()); + eve::data::ByteData data(raw.data(), raw.size()); + auto *module = fontModule(); + CHECK(expectException([&] { module->newFontData(&data, 0); })); + CHECK(expectException([&] { module->newFontData(&data, -8); })); +} diff --git a/test/fonts/FontAwesome.ttf b/test/fonts/FontAwesome.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/test/fonts/FontAwesome.ttf differ diff --git a/test/map.cpp b/test/map.cpp new file mode 100644 index 00000000..6561d392 --- /dev/null +++ b/test/map.cpp @@ -0,0 +1,106 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "map/Map.h" +#include "map/TileLayer.h" +#include "map/TileSystem.h" +#include "map/TileConfig.h" + +#include + +using namespace eve::map; + +TEST_CASE("map.newLayer.ecsView") { + auto *mod = Map::create(); + int before = mod->getLayerCount(); + TileLayer *layer = mod->newLayer(4, 3, 16.f, 16.f); + REQUIRE(layer != nullptr); + CHECK_EQ(layer->getMapWidth(), 4); + CHECK_EQ(layer->getMapHeight(), 3); + CHECK_EQ(layer->getTileWidth(), 16.f); + CHECK_EQ(layer->getTileHeight(), 16.f); + CHECK_EQ(mod->getLayerCount(), before + 1); + CHECK(layer->config()->entity == layer); +} + +TEST_CASE("map.layer.setGetTile") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(3, 2, 32.f, 32.f); + CHECK_EQ(layer->getTile(0, 0), 0); + layer->setTile(1, 1, 5); + CHECK_EQ(layer->getTile(1, 1), 5); + layer->setTile(-1, 0, 9); // out of range no-op + CHECK_EQ(layer->getTile(0, 0), 0); + layer->fill(7); + CHECK_EQ(layer->getTile(0, 0), 7); + CHECK_EQ(layer->getTile(2, 1), 7); + layer->clear(); + CHECK_EQ(layer->getTile(1, 0), 0); +} + +TEST_CASE("map.layer.resizeClears") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(2, 2, 8.f, 8.f); + layer->setTile(0, 0, 3); + layer->resize(4, 1); + CHECK_EQ(layer->getMapWidth(), 4); + CHECK_EQ(layer->getMapHeight(), 1); + CHECK_EQ(layer->getTile(0, 0), 0); +} + +TEST_CASE("map.layer.applyConfigFlat") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(2, 2, 16.f, 16.f); + const char *json = R"({ + "width": 2, + "height": 2, + "tilewidth": 16, + "tileheight": 16, + "data": [1, 2, 3, 4], + "origin": [10, 20], + "layer": 2, + "visible": true, + "tint": [1, 0.5, 0.25, 1] + })"; + CHECK(layer->applyConfig(json)); + CHECK_EQ(layer->getTile(0, 0), 1); + CHECK_EQ(layer->getTile(1, 0), 2); + CHECK_EQ(layer->getTile(0, 1), 3); + CHECK_EQ(layer->getTile(1, 1), 4); + CHECK_EQ(layer->getX(), 10.f); + CHECK_EQ(layer->getY(), 20.f); + CHECK_EQ(layer->getLayer(), 2); + CHECK(layer->isVisible()); +} + +TEST_CASE("map.layer.applyConfigMultiLayerPicksFirst") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(2, 1, 8.f, 8.f); + const char *json = R"({ + "width": 2, + "height": 1, + "tilewidth": 8, + "tileheight": 8, + "layers": [ + { "type": "tilelayer", "width": 2, "height": 1, "data": [9, 8] }, + { "type": "objectgroup", "objects": [] }, + { "type": "tilelayer", "width": 2, "height": 1, "data": [1, 1] } + ] + })"; + CHECK(layer->applyConfig(json)); + CHECK_EQ(layer->getTile(0, 0), 9); + CHECK_EQ(layer->getTile(1, 0), 8); +} + +TEST_CASE("map.tileGid.masksFlipFlags") { + CHECK_EQ(tileGid(0), 0u); + CHECK_EQ(tileGid(5), 5u); + CHECK_EQ(tileGid(0x80000005u), 5u); +} + +TEST_CASE("map.render.nullSafe") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(2, 2, 8.f, 8.f); + layer->fill(1); + TileRenderSystem::render(nullptr); +}