#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "data/DataModule.h" #include "graphics/AmbientOcclusion.h" #include "graphics/AntiAliasing.h" #include "graphics/Canvas.h" #include "graphics/DrawItem2D.h" #include "graphics/Font.h" #include "graphics/GBuffer.h" #include "graphics/GlobalIllumination.h" #include "graphics/Graphics.h" #include "graphics/Grass.h" #include "graphics/Light.h" #include "graphics/Material.h" #include "graphics/Mesh.h" #include "graphics/Outline.h" #include "graphics/Quad.h" #include "graphics/RenderControl.h" #include "graphics/ScreenSpaceReflection.h" #include "graphics/Shader.h" #include "graphics/Texture.h" #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" #include "map/DualGrid.h" #include "map/Map.h" #include "map/MapObject.h" #include "map/TileConfig.h" #include "map/TileLayer.h" #include "map/TileProjection.h" #include "map/TileSystem.h" #include "window/Window.h" #include #include #include #include #include using namespace eve::map; namespace { void hideAllTileLayers() { if (ecs::current()->getManager() == nullptr) return; auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [draw] = *it; draw->visible = false; } } } // namespace 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.tileset.irregularVisualMetadata") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 1, 64.f, 32.f); layer->setTileVisual(7, 10, 20, 96, 128, 48.f, 104.f, 3.f); layer->setTileMetadata(7, 2, 1, false, 4.f); REQUIRE(layer->getTileVisualCount() == 1); const auto &visual = layer->tileset()->visuals[0]; CHECK_EQ(visual.gid, 7); CHECK_EQ(visual.x, 10); CHECK_EQ(visual.y, 20); CHECK_EQ(visual.width, 96); CHECK_EQ(visual.height, 128); CHECK_EQ(visual.pivotX, 48.f); CHECK_EQ(visual.pivotY, 104.f); CHECK_EQ(visual.sortBias, 3.f); CHECK_EQ(visual.footprintW, 2); CHECK(!visual.walkable); CHECK_EQ(visual.cost, 4.f); layer->clearTileVisuals(); CHECK_EQ(layer->getTileVisualCount(), 0); layer->setVisible(false); } TEST_CASE("map.tileset.manifestShapeInMapConfig") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(1, 1, 64.f, 32.f); const char *json = R"({ "width":1,"height":1,"orientation":"isometric","data":[7], "tileset":{ "image":"missing-atlas.png","firstGid":1, "tiles":[{ "gid":7,"region":[10,20,96,128],"pivot":[48,104], "footprint":[2,1],"walkable":false,"cost":4,"sortBias":3 }] } })"; CHECK(layer->applyConfig(json)); REQUIRE(layer->getTileVisualCount() == 1); const auto &visual = layer->tileset()->visuals[0]; CHECK_EQ(visual.gid, 7); CHECK_EQ(visual.width, 96); CHECK_EQ(visual.pivotY, 104.f); CHECK_EQ(visual.footprintW, 2); CHECK(!visual.walkable); layer->setVisible(false); } 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); } TEST_CASE("map.projection.orthogonal") { TileLayer::Config cfg; cfg.mapW = 10; cfg.mapH = 10; cfg.tileW = 32.f; cfg.tileH = 32.f; cfg.originX = 10.f; cfg.originY = 20.f; cfg.orientation = MapOrientation::Orthogonal; float wx = 0, wy = 0; tileToWorld(cfg, 2, 3, wx, wy); CHECK_EQ(wx, 10.f + 2 * 32.f); CHECK_EQ(wy, 20.f + 3 * 32.f); CHECK_EQ(tileToDepthY(cfg, 2, 3), 20.f + 4 * 32.f); int tx = -1, ty = -1; worldToTile(cfg, wx + 1.f, wy + 1.f, tx, ty); CHECK_EQ(tx, 2); CHECK_EQ(ty, 3); } TEST_CASE("map.projection.isometric") { TileLayer::Config cfg; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.originX = 0.f; cfg.originY = 0.f; cfg.orientation = MapOrientation::Isometric; float wx = 0, wy = 0; tileToWorld(cfg, 1, 0, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 16.f); tileToWorld(cfg, 0, 1, wx, wy); CHECK_EQ(wx, -32.f); CHECK_EQ(wy, 16.f); CHECK_EQ(tileToDepthY(cfg, 1, 0), 16.f + 32.f); } TEST_CASE("map.projection.isometric.renderSpacingRoundTrip") { TileLayer::Config cfg; cfg.mapW = 8; cfg.mapH = 8; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.cellGapX = 16.f; cfg.cellGapY = 8.f; cfg.originX = 100.f; cfg.originY = 20.f; cfg.orientation = MapOrientation::Isometric; float wx = 0.f, wy = 0.f; tileToWorld(cfg, 3, 2, wx, wy); CHECK_EQ(wx, 140.f); CHECK_EQ(wy, 120.f); int tx = -1, ty = -1; worldToTile(cfg, wx, wy, tx, ty); CHECK_EQ(tx, 3); CHECK_EQ(ty, 2); } TEST_CASE("map.layer.renderSpacingApi") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 80.f, 40.f); layer->setRenderSpacing(1.25f, 1.5f); CHECK_EQ(layer->getCellGapX(), 20.f); CHECK_EQ(layer->getCellGapY(), 20.f); CHECK_EQ(layer->getRenderSpacingX(), 1.25f); CHECK_EQ(layer->getRenderSpacingY(), 1.5f); } TEST_CASE("map.projection.staggeredYOdd") { TileLayer::Config cfg; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.originX = 0.f; cfg.originY = 0.f; cfg.orientation = MapOrientation::Staggered; cfg.staggerAxis = StaggerAxis::Y; cfg.staggerIndex = StaggerIndex::Odd; float wx = 0, wy = 0; tileToWorld(cfg, 0, 0, wx, wy); CHECK_EQ(wx, 0.f); CHECK_EQ(wy, 0.f); tileToWorld(cfg, 0, 1, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 16.f); } TEST_CASE("map.config.orientationIsometric") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 64.f, 32.f); const char *json = R"({ "width": 2, "height": 2, "tilewidth": 64, "tileheight": 32, "orientation": "isometric", "data": [1, 0, 0, 1] })"; CHECK(layer->applyConfig(json)); CHECK_EQ(static_cast(layer->config()->orientation), static_cast(MapOrientation::Isometric)); } TEST_CASE("map.config.unknownOrientationFails") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(1, 1, 8.f, 8.f); CHECK(!layer->applyConfig(R"({"width":1,"height":1,"orientation":"banana","data":[1]})")); } TEST_CASE("map.decode.zstdFails") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(1, 1, 8.f, 8.f); const char *json = R"({"width":1,"height":1,"tilewidth":8,"tileheight":8, "encoding":"base64","compression":"zstd","data":"AAAA"})"; CHECK(!layer->applyConfig(json)); } TEST_CASE("map.objects.parseObjectGroup") { const char *json = R"({ "width": 2, "height": 1, "tilewidth": 16, "tileheight": 16, "layers": [ { "type": "tilelayer", "width": 2, "height": 1, "data": [1, 0] }, { "type": "objectgroup", "objects": [ { "name": "spawn", "type": "player", "x": 8, "y": 24, "width": 16, "height": 16 } ]} ] })"; std::vector objs; std::string err; auto layers = loadMapText(json, &objs, &err); CHECK(err.empty()); REQUIRE(layers.size() == 1); REQUIRE(objs.size() == 1); CHECK_EQ(objs[0].name, "spawn"); CHECK_EQ(objs[0].type, "player"); CHECK_EQ(objs[0].x, 8.f); CHECK_EQ(objs[0].y, 24.f); } TEST_CASE("map.render.tilesContributeDepthY") { hideAllTileLayers(); auto *mod = Map::create(); TileLayer *layer = mod->newLayer(1, 2, 32.f, 32.f); layer->config()->orientation = MapOrientation::Orthogonal; layer->setOrigin(0, 0); layer->setTile(0, 0, 1); layer->setTile(0, 1, 1); std::vector items; TileRenderSystem::collect(items); REQUIRE(items.size() == 2); eve::graphics::sortDrawItems2D(items); CHECK(items[0].depthY < items[1].depthY); layer->clear(); layer->setVisible(false); } TEST_CASE("map.drawItem.sortByDepthY") { using eve::graphics::DrawItem2D; std::vector items(2); items[0].layer = 0; items[0].depthY = 100.f; items[0].canvas = nullptr; items[1].layer = 0; items[1].depthY = 50.f; items[1].canvas = nullptr; eve::graphics::sortDrawItems2D(items); CHECK_EQ(items[0].depthY, 50.f); CHECK_EQ(items[1].depthY, 100.f); } TEST_CASE("map.projection.isometric.worldToTile") { TileLayer::Config cfg; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.originX = 0.f; cfg.originY = 0.f; cfg.orientation = MapOrientation::Isometric; float wx = 0, wy = 0; tileToWorld(cfg, 1, 0, wx, wy); int tx = -1, ty = -1; worldToTile(cfg, wx, wy, tx, ty); CHECK_EQ(tx, 1); CHECK_EQ(ty, 0); tileToWorld(cfg, 0, 1, wx, wy); worldToTile(cfg, wx, wy, tx, ty); CHECK_EQ(tx, 0); CHECK_EQ(ty, 1); } TEST_CASE("map.projection.staggeredYOdd.worldToTileAndDepth") { TileLayer::Config cfg; cfg.mapW = 4; cfg.mapH = 4; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.originX = 0.f; cfg.originY = 0.f; cfg.orientation = MapOrientation::Staggered; cfg.staggerAxis = StaggerAxis::Y; cfg.staggerIndex = StaggerIndex::Odd; float wx = 0, wy = 0; tileToWorld(cfg, 0, 1, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 16.f); CHECK_EQ(tileToDepthY(cfg, 0, 1), 16.f + 32.f); // Nearest tile center int tx = -1, ty = -1; worldToTile(cfg, wx + 32.f, wy + 16.f, tx, ty); CHECK_EQ(tx, 0); CHECK_EQ(ty, 1); } TEST_CASE("map.projection.staggeredXEven") { TileLayer::Config cfg; cfg.mapW = 3; cfg.mapH = 3; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.orientation = MapOrientation::Staggered; cfg.staggerAxis = StaggerAxis::X; cfg.staggerIndex = StaggerIndex::Even; float wx = 0, wy = 0; tileToWorld(cfg, 0, 0, wx, wy); // major=tx=0 even → staggerIndex Even → shift (since !odd) CHECK_EQ(wx, 0.f); CHECK_EQ(wy, 16.f); tileToWorld(cfg, 1, 0, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 0.f); } TEST_CASE("map.projection.hexagonalYOdd") { TileLayer::Config cfg; cfg.mapW = 3; cfg.mapH = 3; cfg.tileW = 64.f; cfg.tileH = 32.f; cfg.hexSideLength = 16.f; cfg.orientation = MapOrientation::Hexagonal; cfg.staggerAxis = StaggerAxis::Y; cfg.staggerIndex = StaggerIndex::Odd; float wx = 0, wy = 0; tileToWorld(cfg, 0, 0, wx, wy); CHECK_EQ(wx, 0.f); CHECK_EQ(wy, 0.f); // pitchY = (32+16)/2 = 24; row 1 odd → +tw/2 tileToWorld(cfg, 0, 1, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 24.f); CHECK_EQ(tileToDepthY(cfg, 0, 1), 24.f + 32.f); int tx = -1, ty = -1; worldToTile(cfg, wx + 32.f, wy + 16.f, tx, ty); CHECK_EQ(tx, 0); CHECK_EQ(ty, 1); } TEST_CASE("map.decode.base64ZlibRoundTrip") { // 2x2 little-endian GIDs: 1,2,3,4 const unsigned char raw[16] = {1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0}; eve::data::CompressedData *cdata = eve::data::compress("zlib", reinterpret_cast(raw), 16, -1); REQUIRE(cdata != nullptr); const char *compressed = reinterpret_cast(cdata->getData()); const size_t compressedSize = cdata->getSize(); size_t b64len = 0; char *b64raw = eve::data::encode("base64", compressed, compressedSize, b64len); REQUIRE(b64raw != nullptr); REQUIRE(b64len > 0); std::string b64(b64raw, b64len); delete[] b64raw; std::string json = std::string(R"({ "width": 2, "height": 2, "tilewidth": 16, "tileheight": 16, "layers": [{ "type": "tilelayer", "width": 2, "height": 2, "encoding": "base64", "compression": "zlib", "data": ")") + b64 + R"("}]})"; std::string err; auto layers = loadMapText(json, nullptr, &err); CHECK(err.empty()); REQUIRE(layers.size() == 1); CHECK_EQ(layers[0]->getTile(0, 0), 1); CHECK_EQ(layers[0]->getTile(1, 0), 2); CHECK_EQ(layers[0]->getTile(0, 1), 3); CHECK_EQ(layers[0]->getTile(1, 1), 4); delete cdata; } TEST_CASE("map.decode.base64Uncompressed") { const unsigned char raw[16] = {1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0}; size_t b64len = 0; char *b64raw = eve::data::encode("base64", reinterpret_cast(raw), 16, b64len); REQUIRE(b64raw != nullptr); std::string b64(b64raw, b64len); delete[] b64raw; std::string json = std::string(R"({ "width": 2, "height": 2, "tilewidth": 16, "tileheight": 16, "encoding": "base64", "compression": "", "data": ")") + b64 + R"("})"; auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 16.f, 16.f); CHECK(layer->applyConfig(json)); CHECK_EQ(layer->getTile(0, 0), 1); CHECK_EQ(layer->getTile(1, 1), 4); } TEST_CASE("map.load.tiledLikeIsometricWithObjects") { const char *json = R"({ "width": 2, "height": 2, "tilewidth": 64, "tileheight": 32, "orientation": "isometric", "layers": [ { "type": "tilelayer", "width": 2, "height": 2, "data": [1, 0, 0, 2] }, { "type": "objectgroup", "objects": [ { "name": "npc", "type": "talker", "x": 10, "y": 20, "width": 8, "height": 8, "gid": 3 } ]} ] })"; std::vector objs; std::string err; auto layers = loadMapText(json, &objs, &err); CHECK(err.empty()); REQUIRE(layers.size() == 1); CHECK_EQ(static_cast(layers[0]->config()->orientation), static_cast(MapOrientation::Isometric)); CHECK_EQ(layers[0]->getTile(0, 0), 1); CHECK_EQ(layers[0]->getTile(1, 1), 2); float wx = 0, wy = 0; layers[0]->tileToWorld(1, 0, wx, wy); CHECK_EQ(wx, 32.f); CHECK_EQ(wy, 16.f); REQUIRE(objs.size() == 1); CHECK_EQ(objs[0].name, "npc"); CHECK_EQ(objs[0].type, "talker"); CHECK_EQ(objs[0].gid, 3); } TEST_CASE("map.dualGrid.maskAndFrameTable") { CHECK_EQ(dualGridMaskFromCorners(false, false, false, false), 0); CHECK_EQ(dualGridMaskFromCorners(true, false, false, false), 1); CHECK_EQ(dualGridMaskFromCorners(false, true, false, false), 2); CHECK_EQ(dualGridMaskFromCorners(false, false, true, false), 4); CHECK_EQ(dualGridMaskFromCorners(false, false, false, true), 8); CHECK_EQ(dualGridMaskFromCorners(true, true, true, true), 15); // Diagonal TL+BR (classic dual-grid case that blob edge-masks miss). CHECK_EQ(dualGridMaskFromCorners(true, false, false, true), 9); CHECK_EQ(dualGridDefaultFrame(0), -1); CHECK_EQ(dualGridDefaultFrame(15), 6); CHECK_EQ(dualGridDefaultFrame(9), 4); CHECK_EQ(dualGridDefaultFrameTable()[1], 15); } TEST_CASE("map.dualGrid.resolveHalfOffsetAndSize") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 2, 32.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); logic->setOrigin(10.f, 20.f); logic->setTile(0, 0, 1); logic->setTile(1, 1, 1); DualGridOptions opts; opts.useDefaultFrameTable = false; // gid = firstGid + mask opts.firstDisplayGid = 10; opts.hideLogic = true; std::string err; CHECK(resolveDualGrid(logic, display, opts, &err)); CHECK(err.empty()); CHECK_EQ(display->getMapWidth(), 3); CHECK_EQ(display->getMapHeight(), 3); CHECK_EQ(display->getTileWidth(), 32.f); CHECK_EQ(display->getX(), 10.f - 16.f); CHECK_EQ(display->getY(), 20.f - 16.f); CHECK(!logic->isVisible()); CHECK(display->isVisible()); // Display (1,1) samples logic (0,0),(1,0),(0,1),(1,1) → TL+BR = mask 9 CHECK_EQ(dualGridMaskAt(*logic, 1, 1, 0), 9); CHECK_EQ(display->getTile(1, 1), 10 + 9); // Display (0,0) samples only out-of-bounds + logic(0,0) as BR → mask 8 CHECK_EQ(dualGridMaskAt(*logic, 0, 0, 0), 8); CHECK_EQ(display->getTile(0, 0), 10 + 8); // Empty neighborhood CHECK_EQ(display->getTile(2, 0), 0); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.resolveRejectsSameLayer") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 16.f, 16.f); std::string err; CHECK(!resolveDualGrid(layer, layer, DualGridOptions{}, &err)); CHECK(!err.empty()); CHECK(!mod->resolveDualGrid(nullptr, layer)); CHECK(!mod->lastDualGridError().empty()); layer->clear(); layer->setVisible(false); } TEST_CASE("map.dualGrid.filledGidFilter") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(1, 1, 16.f, 16.f); TileLayer *display = mod->newLayer(1, 1, 16.f, 16.f); logic->setTile(0, 0, 5); DualGridOptions opts; opts.filledGid = 9; // 5 should not count opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; opts.hideLogic = false; CHECK(resolveDualGrid(logic, display, opts, nullptr)); // All display cells empty when filled filter mismatches CHECK_EQ(display->getTile(0, 0), 0); CHECK_EQ(display->getTile(1, 0), 0); CHECK_EQ(display->getTile(0, 1), 0); CHECK_EQ(display->getTile(1, 1), 0); CHECK(logic->isVisible()); CHECK(mod->resolveDualGridFilled(logic, display, 5)); // logic(0,0) is BR of display(0,0) and TL of display(1,1). CHECK_EQ(dualGridMaskAt(*logic, 0, 0, 5), 8); CHECK_EQ(dualGridMaskAt(*logic, 1, 1, 5), 1); CHECK_EQ(display->getTile(0, 0), 1 + dualGridDefaultFrame(8)); CHECK_EQ(display->getTile(1, 1), 1 + dualGridDefaultFrame(1)); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.halfOffset.orthogonalIsoStaggerHex") { TileLayer::Config ortho; ortho.orientation = MapOrientation::Orthogonal; ortho.tileW = 32.f; ortho.tileH = 32.f; float ox = 0, oy = 0; dualGridHalfOffset(ortho, ox, oy); CHECK_EQ(ox, -16.f); CHECK_EQ(oy, -16.f); TileLayer::Config iso; iso.orientation = MapOrientation::Isometric; iso.tileW = 64.f; iso.tileH = 32.f; dualGridHalfOffset(iso, ox, oy); CHECK_EQ(ox, 0.f); CHECK_EQ(oy, -16.f); TileLayer::Config stagY; stagY.orientation = MapOrientation::Staggered; stagY.staggerAxis = StaggerAxis::Y; stagY.tileW = 64.f; stagY.tileH = 32.f; dualGridHalfOffset(stagY, ox, oy); CHECK_EQ(ox, -32.f); CHECK_EQ(oy, -8.f); // pitchY = th/2 = 16 → half = 8 TileLayer::Config stagX; stagX.orientation = MapOrientation::Staggered; stagX.staggerAxis = StaggerAxis::X; stagX.tileW = 64.f; stagX.tileH = 32.f; dualGridHalfOffset(stagX, ox, oy); CHECK_EQ(ox, -16.f); // pitchX = tw/2 = 32 → half = 16 CHECK_EQ(oy, -16.f); TileLayer::Config hexY; hexY.orientation = MapOrientation::Hexagonal; hexY.staggerAxis = StaggerAxis::Y; hexY.tileW = 64.f; hexY.tileH = 32.f; hexY.hexSideLength = 16.f; dualGridHalfOffset(hexY, ox, oy); // pitchY = (32+16)/2 = 24 → half = 12 CHECK_EQ(ox, -32.f); CHECK_EQ(oy, -12.f); TileLayer::Config hexX; hexX.orientation = MapOrientation::Hexagonal; hexX.staggerAxis = StaggerAxis::X; hexX.tileW = 64.f; hexX.tileH = 32.f; hexX.hexSideLength = 16.f; dualGridHalfOffset(hexX, ox, oy); // pitchX = (64+16)/2 = 40 → half = 20 CHECK_EQ(ox, -20.f); CHECK_EQ(oy, -16.f); } TEST_CASE("map.dualGrid.resolve.isometric") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 2, 64.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); logic->config()->orientation = MapOrientation::Isometric; logic->setOrigin(100.f, 200.f); logic->setTile(0, 0, 1); logic->setTile(1, 1, 1); DualGridOptions opts; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; opts.hideLogic = true; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(static_cast(display->config()->orientation), static_cast(MapOrientation::Isometric)); CHECK_EQ(display->getMapWidth(), 3); CHECK_EQ(display->getMapHeight(), 3); CHECK_EQ(display->getX(), 100.f); // iso: offX = 0 CHECK_EQ(display->getY(), 200.f - 16.f); // offY = -tileH/2 CHECK_EQ(mod->dualGridOffsetX(logic), 0.f); CHECK_EQ(mod->dualGridOffsetY(logic), -16.f); // Index-space mask unchanged from orthogonal dual-grid. CHECK_EQ(dualGridMaskAt(*logic, 1, 1, 0), 9); CHECK_EQ(display->getTile(1, 1), 1 + 9); // display(1,0) == logic fractional (0.5, -0.5) under iso projection: // wx = ox + (1-0)*tw/2 = ox+32, wy = (oy-th/2) + (1+0)*th/2 = oy CHECK_EQ(display->tileToWorldX(1, 0), 100.f + 32.f); CHECK_EQ(display->tileToWorldY(1, 0), 200.f); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.resolve.staggeredYOdd") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(3, 2, 64.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); { auto lc = logic->config(); lc->orientation = MapOrientation::Staggered; lc->staggerAxis = StaggerAxis::Y; lc->staggerIndex = StaggerIndex::Odd; } logic->setOrigin(0.f, 0.f); logic->fill(1); DualGridOptions opts; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 20; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(static_cast(display->config()->orientation), static_cast(MapOrientation::Staggered)); CHECK_EQ(static_cast(display->config()->staggerAxis), static_cast(StaggerAxis::Y)); CHECK_EQ(static_cast(display->config()->staggerIndex), static_cast(StaggerIndex::Odd)); CHECK_EQ(display->getMapWidth(), 4); CHECK_EQ(display->getMapHeight(), 3); CHECK_EQ(display->getX(), -32.f); CHECK_EQ(display->getY(), -8.f); // Interior display(1,1) sees four filled logic cells → mask 15 CHECK_EQ(dualGridMaskAt(*logic, 1, 1, 0), 15); CHECK_EQ(display->getTile(1, 1), 20 + 15); // Corner display(0,0) only BR → mask 8 CHECK_EQ(display->getTile(0, 0), 20 + 8); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.resolve.staggeredXEven") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 3, 64.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); { auto lc = logic->config(); lc->orientation = MapOrientation::Staggered; lc->staggerAxis = StaggerAxis::X; lc->staggerIndex = StaggerIndex::Even; } logic->setOrigin(10.f, 20.f); logic->setTile(0, 1, 1); logic->setTile(1, 1, 1); DualGridOptions opts; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(static_cast(display->config()->staggerAxis), static_cast(StaggerAxis::X)); CHECK_EQ(static_cast(display->config()->staggerIndex), static_cast(StaggerIndex::Even)); CHECK_EQ(display->getX(), 10.f - 16.f); CHECK_EQ(display->getY(), 20.f - 16.f); // display(1,2) samples logic (0,1),(1,1),(0,2),(1,2) → TL+TR = mask 3 CHECK_EQ(dualGridMaskAt(*logic, 1, 2, 0), 3); CHECK_EQ(display->getTile(1, 2), 1 + 3); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.resolve.hexagonal") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 2, 64.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); { auto lc = logic->config(); lc->orientation = MapOrientation::Hexagonal; lc->staggerAxis = StaggerAxis::Y; lc->staggerIndex = StaggerIndex::Odd; lc->hexSideLength = 16.f; } logic->setOrigin(5.f, 7.f); logic->setTile(0, 0, 1); DualGridOptions opts; opts.useDefaultFrameTable = true; opts.firstDisplayGid = 1; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(static_cast(display->config()->orientation), static_cast(MapOrientation::Hexagonal)); CHECK_EQ(display->config()->hexSideLength, 16.f); CHECK_EQ(display->getX(), 5.f - 32.f); CHECK_EQ(display->getY(), 7.f - 12.f); // pitchY=24 → half=12 CHECK_EQ(display->getTile(0, 0), 1 + dualGridDefaultFrame(8)); CHECK_EQ(display->getTile(1, 1), 1 + dualGridDefaultFrame(1)); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.flipFlagsCountAsFilled") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(1, 1, 16.f, 16.f); TileLayer *display = mod->newLayer(1, 1, 16.f, 16.f); // Tiled horizontal flip flag on GID 1 logic->setTile(0, 0, int(0x80000001u)); DualGridOptions opts; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(dualGridMaskAt(*logic, 0, 0, 0), 8); CHECK_EQ(display->getTile(0, 0), 1 + 8); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.noHalfOffsetKeepsOrigin") { auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 1, 32.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 8.f, 8.f); logic->config()->orientation = MapOrientation::Isometric; logic->setOrigin(40.f, 50.f); logic->setTile(0, 0, 1); DualGridOptions opts; opts.applyHalfOffset = false; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; opts.hideLogic = false; CHECK(resolveDualGrid(logic, display, opts, nullptr)); CHECK_EQ(display->getX(), 40.f); CHECK_EQ(display->getY(), 50.f); CHECK_EQ(static_cast(display->config()->orientation), static_cast(MapOrientation::Isometric)); CHECK(logic->isVisible()); logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.dualGrid.render.isometricDisplayDepth") { hideAllTileLayers(); auto *mod = Map::create(); TileLayer *logic = mod->newLayer(2, 2, 64.f, 32.f); TileLayer *display = mod->newLayer(1, 1, 64.f, 32.f); logic->config()->orientation = MapOrientation::Isometric; logic->setOrigin(0.f, 0.f); logic->fill(1); DualGridOptions opts; opts.useDefaultFrameTable = false; opts.firstDisplayGid = 1; CHECK(resolveDualGrid(logic, display, opts, nullptr)); std::vector items; TileRenderSystem::collect(items); // Interior + border dual cells that are non-empty on a full 2x2 logic map: // display 3x3, only mask0 empty — all 9 have some contribution from filled // neighbors except none are fully empty when logic is full... actually edges // still have non-zero masks. All 9 display cells should be non-empty. CHECK_EQ(items.size(), 9u); eve::graphics::sortDrawItems2D(items); for (size_t i = 1; i < items.size(); ++i) { CHECK(items[i - 1].depthY <= items[i].depthY); } logic->clear(); display->clear(); logic->setVisible(false); display->setVisible(false); } TEST_CASE("map.render.orthoAndIsoPreview") { hideAllTileLayers(); auto *win = eve::window::Window::create(); auto *gfx = eve::graphics::Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings s; s.width = 640; s.height = 400; s.centered = true; REQUIRE(win->setWindowSettings(s)); auto *mod = Map::create(); // Left: orthogonal concentric rings (solidForGid colors, no atlas needed). TileLayer *ortho = mod->newLayer(10, 8, 28.f, 28.f); ortho->config()->orientation = MapOrientation::Orthogonal; ortho->setOrigin(40.f, 40.f); for (int y = 0; y < 8; ++y) { for (int x = 0; x < 10; ++x) { const int ring = std::min({x, y, 9 - x, 7 - y}); ortho->setTile(x, y, uint32_t(1 + ring)); } } ortho->setVisible(true); // Right: isometric diamond so projection is visibly different. TileLayer *iso = mod->newLayer(6, 6, 48.f, 24.f); iso->config()->orientation = MapOrientation::Isometric; iso->setOrigin(420.f, 80.f); for (int y = 0; y < 6; ++y) { for (int x = 0; x < 6; ++x) { iso->setTile(x, y, uint32_t(3 + ((x + y) % 4))); } } iso->setVisible(true); std::vector items; TileRenderSystem::collect(items); REQUIRE(items.size() == 10u * 8u + 6u * 6u); gfx->setBackgroundColorRGBA(0.08f, 0.09f, 0.12f, 1.f); for (int frame = 0; frame < 75; ++frame) { ortho->setOrigin(40.f + float(frame) * 0.15f, 40.f); iso->setOrigin(420.f, 80.f + float(std::sin(float(frame) * 0.08f)) * 6.f); gfx->clearScreen(); mod->render(gfx); gfx->present(); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } SDL_Delay(16); } ortho->setVisible(false); iso->setVisible(false); win->close(); }