From dc7aec507fc35e9b70beab7be830dfac0d906b75 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 18:59:44 -0700 Subject: [PATCH] feat(editor): add --export-zone-spawn-png for top-down spawn-position maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a top-down PNG showing creature + object spawn positions colored by type. Bound by the zone's tile range so the image is properly framed at zone scale: wowee_editor --export-zone-spawn-png custom_zones/MyZone # -> custom_zones/MyZone/MyZone_spawns.png Layout: - Tile-grid lines at tile boundaries (subtle grey on dark grey) - Red 3×3 dots: creature spawns - Green 3×3 dots: M2 object placements - Blue 3×3 dots: WMO object placements - 256 px per tile (so a 4-tile zone is 512×512); cap at 4096 largest dim for huge multi-tile projects WoW coord -> image transform: +X world is north (up in image), +Y world is west (left in image). Same convention --info-tilemap uses, so the spawn map and the tilemap line up visually. Useful for design review ('does the spawn distribution match the encounter design?'), screenshot bait for blog posts, and instant visual validation of new content before opening the GUI. Verified on a 1-tile mvp-zone with 3 creatures + 1 object plotted: 256×256 RGB PNG, dots placed at expected positions, --info-png confirms the output is well-formed (8-bit RGB, 2184 bytes). --- tools/editor/main.cpp | 134 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 5fca8737..16b609fc 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -518,6 +518,8 @@ static void printUsage(const char* argv0) { std::printf(" List external M2/WMO model paths a zone references (objects + WOB doodads)\n"); std::printf(" --export-zone-deps-md [out.md]\n"); std::printf(" Markdown dep table for a zone (with on-disk presence column)\n"); + std::printf(" --export-zone-spawn-png [out.png]\n"); + std::printf(" Top-down PNG of creature + object spawn positions (per-tile-bounded)\n"); std::printf(" --check-zone-refs [--json]\n"); std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n"); std::printf(" --check-zone-content [--json]\n"); @@ -841,7 +843,7 @@ int main(int argc, char* argv[]) { "--scaffold-zone", "--mvp-zone", "--add-tile", "--remove-tile", "--list-tiles", "--for-each-zone", "--for-each-tile", "--zone-stats", "--info-tilemap", "--list-zone-deps", "--check-zone-refs", "--check-zone-content", - "--export-zone-deps-md", + "--export-zone-deps-md", "--export-zone-spawn-png", "--add-creature", "--add-object", "--add-quest", "--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward", "--remove-quest-objective", "--clone-quest", "--clone-creature", @@ -12713,6 +12715,136 @@ int main(int argc, char* argv[]) { std::printf(" %zu M2 placements, %zu WMO placements, %zu WOB doodad refs\n", directM2.size(), directWMO.size(), doodadM2.size()); return 0; + } else if (std::strcmp(argv[i], "--export-zone-spawn-png") == 0 && i + 1 < argc) { + // Top-down PNG of spawn positions colored by type. Bound by + // the zone's tile range so the image is properly framed. + // Useful for design review (does the spawn distribution + // match the intended encounter design?) and for showing + // collaborators 'where are the mobs'. + std::string zoneDir = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "export-zone-spawn-png: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, + "export-zone-spawn-png: parse failed\n"); + return 1; + } + if (zm.tiles.empty()) { + std::fprintf(stderr, "export-zone-spawn-png: zone has no tiles\n"); + return 1; + } + if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + "_spawns.png"; + // Compute world-space bounds from manifest tiles. Same math + // as --info-zone-extents. + constexpr float kTileSize = 533.33333f; + int tileMinX = 64, tileMaxX = -1; + int tileMinY = 64, tileMaxY = -1; + for (const auto& [tx, ty] : zm.tiles) { + tileMinX = std::min(tileMinX, tx); + tileMaxX = std::max(tileMaxX, tx); + tileMinY = std::min(tileMinY, ty); + tileMaxY = std::max(tileMaxY, ty); + } + float worldMinX = (32.0f - tileMaxY - 1) * kTileSize; + float worldMaxX = (32.0f - tileMinY) * kTileSize; + float worldMinY = (32.0f - tileMaxX - 1) * kTileSize; + float worldMaxY = (32.0f - tileMinX) * kTileSize; + // Image dimensions: 256px per tile so detail is visible + // without inflating per-pixel cost. + int tilesX = tileMaxY - tileMinY + 1; // tile.y maps to world.x + int tilesY = tileMaxX - tileMinX + 1; + const int kPxPerTile = 256; + int imgW = tilesX * kPxPerTile; + int imgH = tilesY * kPxPerTile; + // Cap output size — 16-tile-wide projects shouldn't exceed + // 4096 wide. Scale down if needed. + int maxDim = std::max(imgW, imgH); + if (maxDim > 4096) { + int divisor = (maxDim + 4095) / 4096; + imgW = std::max(64, imgW / divisor); + imgH = std::max(64, imgH / divisor); + } + std::vector img(imgW * imgH * 3, 32); // dark grey background + // Tile-grid lines so the boundary is visible. + for (int t = 1; t < tilesX; ++t) { + int x = (t * imgW) / tilesX; + if (x >= 0 && x < imgW) { + for (int y = 0; y < imgH; ++y) { + size_t off = (y * imgW + x) * 3; + img[off] = img[off+1] = img[off+2] = 64; + } + } + } + for (int t = 1; t < tilesY; ++t) { + int y = (t * imgH) / tilesY; + if (y >= 0 && y < imgH) { + for (int x = 0; x < imgW; ++x) { + size_t off = (y * imgW + x) * 3; + img[off] = img[off+1] = img[off+2] = 64; + } + } + } + // Plot spawn points. Map world (X, Y) to image (px, py): + // px = (worldMaxX - X) / (worldMaxX - worldMinX) * imgW + // py = (worldMaxY - Y) / (worldMaxY - worldMinY) * imgH + // since +X world is north (up) and +Y world is west (left) + // in WoW coords. + float wRangeX = worldMaxX - worldMinX; + float wRangeY = worldMaxY - worldMinY; + auto plotPoint = [&](float wx, float wy, uint8_t r, uint8_t g, uint8_t b) { + if (wRangeX <= 0 || wRangeY <= 0) return; + int px = static_cast((worldMaxX - wx) / wRangeX * imgW); + int py = static_cast((worldMaxY - wy) / wRangeY * imgH); + // 3×3 dot. + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + int x = px + dx, y = py + dy; + if (x < 0 || x >= imgW || y < 0 || y >= imgH) continue; + size_t off = (y * imgW + x) * 3; + img[off] = r; img[off+1] = g; img[off+2] = b; + } + } + }; + // Creatures = red. + wowee::editor::NpcSpawner sp; + int creaturesPlotted = 0; + if (sp.loadFromFile(zoneDir + "/creatures.json")) { + for (const auto& s : sp.getSpawns()) { + plotPoint(s.position.x, s.position.y, 220, 60, 60); + creaturesPlotted++; + } + } + // Objects = green (M2) / blue (WMO). + wowee::editor::ObjectPlacer op; + int objectsPlotted = 0; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (const auto& o : op.getObjects()) { + if (o.type == wowee::editor::PlaceableType::M2) { + plotPoint(o.position.x, o.position.y, 60, 200, 60); + } else { + plotPoint(o.position.x, o.position.y, 60, 120, 220); + } + objectsPlotted++; + } + } + if (!stbi_write_png(outPath.c_str(), imgW, imgH, 3, + img.data(), imgW * 3)) { + std::fprintf(stderr, + "export-zone-spawn-png: stbi_write_png failed\n"); + return 1; + } + std::printf("Wrote %s\n", outPath.c_str()); + std::printf(" %dx%d px, tile grid %dx%d, %d creatures (red), %d objects (green/blue)\n", + imgW, imgH, tilesX, tilesY, creaturesPlotted, objectsPlotted); + return 0; } else if (std::strcmp(argv[i], "--check-zone-refs") == 0 && i + 1 < argc) { // Cross-reference checker: every model path in objects.json // must resolve as either an open WOM/WOB sidecar or a