feat(editor): NPC scatter tool, adjacent tile creation, multi-tile prep

- Scatter tool: place N creatures in a radius around cursor position
  with random rotation and uniform disk distribution
- File > Add Adjacent Tile: creates and exports a blank tile N/S/E/W
  of current (foundation for multi-tile zone editing)
- Scatter UI: count slider (1-30), radius slider (10-200)
- Scatter places all copies with same stats/behavior as template
This commit is contained in:
Kelsi 2026-05-05 04:14:29 -07:00
parent 6e24e08818
commit 48026421c9
5 changed files with 67 additions and 0 deletions

View file

@ -603,6 +603,22 @@ void EditorApp::setGizmoAxis(TransformAxis axis) {
viewport_.getGizmo().setTarget(sel->position, sel->scale);
}
void EditorApp::addAdjacentTile(int offsetX, int offsetY) {
if (!terrain_.isLoaded()) return;
int newX = loadedTileX_ + offsetX;
int newY = loadedTileY_ + offsetY;
if (newX < 0 || newX > 63 || newY < 0 || newY > 63) return;
// Create a blank tile adjacent to current
auto adj = TerrainEditor::createBlankTerrain(newX, newY, terrain_.chunks[0].position[2],
Biome::Grassland);
// Stitch edges: copy border heights from current terrain to adjacent
// (This is a simplified version — full multi-tile needs a different architecture)
LOG_INFO("Adjacent tile created at [", newX, ",", newY, "] (not yet rendered in viewport)");
ADTWriter::write(adj, "output/" + loadedMap_ + "/" + loadedMap_ + "_" +
std::to_string(newX) + "_" + std::to_string(newY) + ".adt");
}
void EditorApp::snapSelectedToGround() {
auto* sel = objectPlacer_.getSelected();
if (!sel || !terrain_.isLoaded()) return;

View file

@ -64,6 +64,9 @@ public:
void startGizmoMode(TransformMode mode);
void setGizmoAxis(TransformAxis axis);
void snapSelectedToGround();
// Multi-tile support
void addAdjacentTile(int offsetX, int offsetY);
TransformGizmo& getGizmo() { return viewport_.getGizmo(); }
bool shouldOpenContextMenu() const { return openContextMenu_; }
void clearContextMenuFlag() { openContextMenu_ = false; }

View file

@ -73,6 +73,13 @@ void EditorUI::renderMenuBar(EditorApp& app) {
app.quickSave();
if (ImGui::MenuItem("Export Zone...", nullptr, false, app.hasTerrainLoaded()))
showSaveDialog_ = true;
if (ImGui::BeginMenu("Add Adjacent Tile", app.hasTerrainLoaded())) {
if (ImGui::MenuItem("North (+X)")) app.addAdjacentTile(1, 0);
if (ImGui::MenuItem("South (-X)")) app.addAdjacentTile(-1, 0);
if (ImGui::MenuItem("East (+Y)")) app.addAdjacentTile(0, 1);
if (ImGui::MenuItem("West (-Y)")) app.addAdjacentTile(0, -1);
ImGui::EndMenu();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit", "Alt+F4")) app.requestQuit();
ImGui::EndMenu();
@ -537,6 +544,25 @@ void EditorUI::renderNpcPanel(EditorApp& app) {
if (ImGui::Button("Deselect##npc")) spawner.clearSelection();
}
ImGui::Separator();
// Scatter tool
if (ImGui::CollapsingHeader("Scatter Tool")) {
static int scatterCount = 5;
static float scatterRadius = 50.0f;
ImGui::SliderInt("Count", &scatterCount, 1, 30);
ImGui::SliderFloat("Radius##scatter", &scatterRadius, 10.0f, 200.0f);
auto& brush = app.getTerrainEditor().brush();
if (ImGui::Button("Scatter at Cursor", ImVec2(-1, 0))) {
if (brush.isActive() && !tmpl.modelPath.empty()) {
spawner.scatter(tmpl, brush.getPosition(), scatterRadius, scatterCount);
app.markObjectsDirty();
}
}
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1),
"Places %d copies in %.0f radius", scatterCount, scatterRadius);
}
ImGui::Separator();
static char npcPath[256] = "output/creatures.json";
ImGui::InputText("File##npc", npcPath, sizeof(npcPath));

View file

@ -3,6 +3,7 @@
#include <fstream>
#include <sstream>
#include <cmath>
#include <random>
#include <algorithm>
#include <filesystem>
@ -102,6 +103,23 @@ bool NpcSpawner::saveToFile(const std::string& path) const {
return true;
}
void NpcSpawner::scatter(const CreatureSpawn& base, const glm::vec3& center,
float radius, int count) {
std::mt19937 rng(static_cast<uint32_t>(center.x * 100 + center.y * 37));
std::uniform_real_distribution<float> distAngle(0.0f, 6.2831853f);
std::uniform_real_distribution<float> distDist(0.0f, radius);
std::uniform_real_distribution<float> distRot(0.0f, 360.0f);
for (int i = 0; i < count; i++) {
float angle = distAngle(rng);
float dist = std::sqrt(distDist(rng) / radius) * radius;
CreatureSpawn s = base;
s.position = center + glm::vec3(std::cos(angle) * dist, std::sin(angle) * dist, 0.0f);
s.orientation = distRot(rng);
placeCreature(s);
}
}
bool NpcSpawner::loadFromFile(const std::string& path) {
// Simple JSON-ish parser for our format — full JSON parsing would need a library
LOG_INFO("NPC spawn loading not yet implemented for: ", path);

View file

@ -81,6 +81,10 @@ public:
// Template creature for placement
CreatureSpawn& getTemplate() { return template_; }
// Scatter: place multiple copies in a radius around a point
void scatter(const CreatureSpawn& base, const glm::vec3& center,
float radius, int count);
private:
uint32_t nextId();
std::vector<CreatureSpawn> spawns_;