Files
EVEngine/test/procgen.cpp
T
西风逍遥游andCursor Agent 6f290b2a1d feat(procgen): 程序化生成模块 Phase A/B(地图 + 贴图) (#5)
* docs(procgen): add procedural generation module design

Capture confirmed product direction (runtime map-first, textures
second, pixel/low-poly RPG) plus API sketch, algorithm registry,
configurable outputs, and Phase A checklist.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(procgen): list procgen in module summary table

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(procgen): Phase A runtime map generation with DTL

Add eve.Procgen module: semantic Grid2D, algorithm registry, palettes,
and TileLayer/JSON outputs. Built-ins use Dungeon Template Library
(BSP, cellular cave, maze, perlin terrain) plus local drunkard walk.
Includes tests and examples/procgen demo.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(procgen): Phase B runtime pixel texture recipes

Add seamless value-noise texture generation with banded color ramps
for soil/stone/marble/water/sky_cloud, ImageData/Texture/normal outputs,
and script bindings for gfx.newTexture / drawTexturedRect.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(test): qualify eve::image::ImageData in procgen tests

Bare image:: failed to resolve under `using namespace eve::procgen`,
breaking unit_test builds on Linux/Windows/macOS CI.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(procgen): link EVProcgen in tests and fix Apple Clang DTL build

unit_test was missing EVProcgen, causing undefined references on Linux/Windows.
Avoid the DTL umbrella header (broken Voronoi CRTP on Apple Clang) and provide
DrawJagged* compat shims that define the missing drawOperatorArray.
Verified locally: linux-debug build, unit_test link, and all 13 procgen ctests.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-09 08:47:27 +08:00

280 lines
8.8 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "procgen/Procgen.h"
#include "procgen/GeneratorRegistry.h"
#include "procgen/Semantic.h"
#include "procgen/JsonExport.h"
#include "procgen/texture/TextureRecipe.h"
#include "procgen/texture/NoiseField.h"
#include "procgen/texture/ColorRamp.h"
#include "map/TileLayer.h"
#include "image/ImageData.h"
#include <cstring>
#include <set>
#include <string>
#include <vector>
using namespace eve::procgen;
namespace {
bool gridsEqual(const Grid2D &a, const Grid2D &b) {
if (a.getWidth() != b.getWidth() || a.getHeight() != b.getHeight()) return false;
return a.cells() == b.cells();
}
int countSemantic(const Grid2D &g, int semantic) {
int n = 0;
for (uint32_t c : g.cells())
if (int(c) == semantic) ++n;
return n;
}
bool hasWalkablePath(const Grid2D &g) {
// BFS from first floor/corridor cell; ensure >1 walkable and connected component covers all.
const int w = g.getWidth();
const int h = g.getHeight();
auto walkable = [&](int x, int y) {
if (x < 0 || y < 0 || x >= w || y >= h) return false;
const int c = g.getCell(x, y);
return c == int(Semantic::Floor) || c == int(Semantic::Corridor);
};
int start = -1;
int total = 0;
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
if (walkable(x, y)) {
++total;
if (start < 0) start = y * w + x;
}
}
}
if (total < 2 || start < 0) return false;
std::vector<char> seen(size_t(w * h), 0);
std::vector<int> q;
q.push_back(start);
seen[size_t(start)] = 1;
size_t qi = 0;
int reached = 0;
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};
while (qi < q.size()) {
const int i = q[qi++];
const int x = i % w;
const int y = i / w;
++reached;
for (int d = 0; d < 4; ++d) {
const int nx = x + dx[d];
const int ny = y + dy[d];
if (!walkable(nx, ny)) continue;
const int ni = ny * w + nx;
if (seen[size_t(ni)]) continue;
seen[size_t(ni)] = 1;
q.push_back(ni);
}
}
return reached == total;
}
} // namespace
TEST_CASE("procgen.semantic.names") {
CHECK_EQ(std::string(semanticName(Semantic::Wall)), "wall");
CHECK_EQ(semanticId("floor"), Semantic::Floor);
CHECK_EQ(semanticId("nope"), Semantic::Empty);
}
TEST_CASE("procgen.registry.builtins") {
GeneratorRegistry::instance().registerBuiltins();
CHECK(GeneratorRegistry::instance().has("dungeon.bsp"));
CHECK(GeneratorRegistry::instance().has("cave.cellular"));
CHECK(GeneratorRegistry::instance().has("cave.drunkard"));
CHECK(GeneratorRegistry::instance().has("maze.backtrack"));
CHECK(GeneratorRegistry::instance().has("noise.terrain"));
}
TEST_CASE("procgen.dungeon.bsp.reproducible") {
Params p;
p.setSeed(42);
p.setSize(48, 36);
Grid2D a, b;
std::string err;
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, a, err));
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, b, err));
CHECK(gridsEqual(a, b));
CHECK(countSemantic(a, int(Semantic::Floor)) > 0);
CHECK(countSemantic(a, int(Semantic::Wall)) > 0);
CHECK(a.getObjectCount() >= 2);
CHECK(hasWalkablePath(a));
}
TEST_CASE("procgen.cave.cellular.reproducible") {
Params p;
p.setSeed(7);
p.setSize(32, 24);
p.setInt("loops", 4);
p.setFloat("fill", 0.45f);
Grid2D a, b;
std::string err;
CHECK(GeneratorRegistry::instance().generate("cave.cellular", p, a, err));
CHECK(GeneratorRegistry::instance().generate("cave.cellular", p, b, err));
CHECK(gridsEqual(a, b));
CHECK(countSemantic(a, int(Semantic::Floor)) > 0);
}
TEST_CASE("procgen.cave.drunkard.reproducible") {
Params p;
p.setSeed(99);
p.setSize(40, 30);
p.setFloat("floorPct", 0.4f);
Grid2D a, b;
std::string err;
CHECK(GeneratorRegistry::instance().generate("cave.drunkard", p, a, err));
CHECK(GeneratorRegistry::instance().generate("cave.drunkard", p, b, err));
CHECK(gridsEqual(a, b));
CHECK(countSemantic(a, int(Semantic::Floor)) > 10);
}
TEST_CASE("procgen.maze.backtrack.reproducible") {
Params p;
p.setSeed(123);
p.setSize(21, 15);
Grid2D a, b;
std::string err;
CHECK(GeneratorRegistry::instance().generate("maze.backtrack", p, a, err));
CHECK(GeneratorRegistry::instance().generate("maze.backtrack", p, b, err));
CHECK(gridsEqual(a, b));
CHECK(hasWalkablePath(a));
}
TEST_CASE("procgen.noise.terrain.reproducible") {
Params p;
p.setSeed(55);
p.setSize(32, 32);
p.setFloat("frequency", 5.f);
p.setInt("octaves", 3);
Grid2D a, b;
std::string err;
CHECK(GeneratorRegistry::instance().generate("noise.terrain", p, a, err));
CHECK(GeneratorRegistry::instance().generate("noise.terrain", p, b, err));
CHECK(gridsEqual(a, b));
// Multiple biome bands expected.
std::set<int> kinds;
for (uint32_t c : a.cells()) kinds.insert(int(c));
CHECK(kinds.size() >= 2);
}
TEST_CASE("procgen.palette.applyToLayer") {
Procgen *mod = Procgen::create();
Params p;
p.setSeed(1);
p.setSize(16, 12);
Grid2D *grid = mod->generate("dungeon.bsp", &p);
CHECK(grid != nullptr);
mod->setPaletteGid("test", "wall", 10);
mod->setPaletteGid("test", "floor", 20);
mod->setPaletteGid("test", "corridor", 21);
eve::map::TileLayer *layer = eve::map::TileLayer::createLayer(16, 12, 16.f, 16.f);
CHECK(mod->applyToLayer(grid, "test", layer));
bool sawMapped = false;
for (int y = 0; y < 12; ++y) {
for (int x = 0; x < 16; ++x) {
const int gid = layer->getTile(x, y);
const int sem = grid->getCell(x, y);
if (sem == int(Semantic::Wall)) {
CHECK_EQ(gid, 10);
sawMapped = true;
} else if (sem == int(Semantic::Floor)) {
CHECK_EQ(gid, 20);
sawMapped = true;
} else if (sem == int(Semantic::Corridor)) {
CHECK_EQ(gid, 21);
sawMapped = true;
}
}
}
CHECK(sawMapped);
delete grid;
layer->release();
}
TEST_CASE("procgen.json.export") {
Params p;
p.setSeed(3);
p.setSize(12, 10);
Grid2D g;
std::string err;
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, g, err));
const std::string json = gridToJson(g);
CHECK(json.find("\"width\":12") != std::string::npos);
CHECK(json.find("\"cells\":") != std::string::npos);
CHECK(json.find("spawn") != std::string::npos);
}
TEST_CASE("procgen.texture.noiseField.seamlessReproducible") {
NoiseField a{42, 8, 8};
NoiseField b{42, 8, 8};
CHECK_EQ(a.fbm(1.25f, 3.5f, 4), b.fbm(1.25f, 3.5f, 4));
// Period wrap: x and x+period should match on lattice contribution.
CHECK_EQ(a.hash01(0, 0), a.hash01(8, 0));
CHECK_EQ(a.hash01(0, 0), a.hash01(0, 8));
}
TEST_CASE("procgen.texture.colorRamp.banded") {
ColorRamp ramp;
ramp.add(0.f, 0, 0, 0);
ramp.add(1.f, 255, 255, 255);
const Rgba8 c0 = ramp.sampleBanded(0.1f, 3);
const Rgba8 c1 = ramp.sampleBanded(0.9f, 3);
CHECK(c0.r < c1.r);
}
TEST_CASE("procgen.texture.recipes.reproducible") {
TextureRecipeRegistry::instance().registerBuiltins();
const char *ids[] = {"tex.soil", "tex.stone", "tex.marble", "tex.water", "tex.sky_cloud"};
for (const char *id : ids) {
Params p;
p.setSeed(11);
p.setSize(32, 32);
p.setInt("colors", 5);
p.setInt("pixelSize", 2);
p.setInt("seamless", 1);
std::string err;
// Fully qualify: `using namespace eve::procgen` would make bare `image::`
// look outside `eve::image`.
eve::image::ImageData *a = TextureRecipeRegistry::instance().generate(id, p, err);
eve::image::ImageData *b = TextureRecipeRegistry::instance().generate(id, p, err);
REQUIRE(a != nullptr);
REQUIRE(b != nullptr);
CHECK_EQ(a->getWidth(), 32);
CHECK_EQ(a->getHeight(), 32);
CHECK_EQ(a->getFormat(), std::string("RGBA8"));
CHECK_EQ(a->getSize(), b->getSize());
CHECK(std::memcmp(a->getData(), b->getData(), a->getSize()) == 0);
delete a;
delete b;
}
}
TEST_CASE("procgen.texture.generateImage.andNormal") {
Procgen *mod = Procgen::create();
Params p;
p.setSeed(3);
p.setSize(48, 48);
p.setInt("colors", 6);
eve::image::ImageData *img = mod->generateImage("tex.marble", &p);
REQUIRE(img != nullptr);
eve::image::ImageData *nrm = mod->generateNormalImage("tex.marble", &p);
REQUIRE(nrm != nullptr);
CHECK_EQ(nrm->getWidth(), 48);
CHECK_EQ(nrm->getHeight(), 48);
delete img;
delete nrm;
CHECK(mod->hasTextureRecipe("tex.soil"));
CHECK(mod->getTextureRecipeCount() >= 5);
}