From a91233a6eca602373f0d8f71df5ed8d952ceab2a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 04:44:54 -0700 Subject: [PATCH] feat(editor): erosion brush, NPC load, auto-save - Erode brush mode: simulates water erosion by moving height downhill based on slope, creating natural drainage patterns and gullies - NPC JSON loader: File > Load NPCs parses saved creatures.json back into the spawn list (round-trip save/load now works) - Auto-save: every 5 minutes when unsaved changes exist, exports the full zone (ADT + WDT + creatures) to the output directory - Sculpt mode now has 6 brush types: Raise/Lower/Smooth/Flatten/Level/Erode --- tools/editor/editor_app.cpp | 10 ++++ tools/editor/editor_app.hpp | 3 + tools/editor/editor_brush.hpp | 3 +- tools/editor/editor_ui.cpp | 11 +++- tools/editor/npc_spawner.cpp | 98 ++++++++++++++++++++++++++++++++- tools/editor/terrain_editor.cpp | 45 +++++++++++++++ tools/editor/terrain_editor.hpp | 1 + 7 files changed, 164 insertions(+), 7 deletions(-) diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp index 6eb8b004..c726b60a 100644 --- a/tools/editor/editor_app.cpp +++ b/tools/editor/editor_app.cpp @@ -81,6 +81,16 @@ void EditorApp::run() { // Handle pending UI actions ui_.processActions(*this); + // Auto-save + if (autoSaveEnabled_ && terrain_.isLoaded() && terrainEditor_.hasUnsavedChanges()) { + autoSaveTimer_ += dt; + if (autoSaveTimer_ >= autoSaveInterval_) { + autoSaveTimer_ = 0.0f; + quickSave(); + LOG_INFO("Auto-saved zone"); + } + } + // Refresh dirty terrain chunks refreshDirtyChunks(); diff --git a/tools/editor/editor_app.hpp b/tools/editor/editor_app.hpp index 79b40017..5db202b8 100644 --- a/tools/editor/editor_app.hpp +++ b/tools/editor/editor_app.hpp @@ -116,6 +116,9 @@ private: bool openContextMenu_ = false; std::string lastSavePath_; std::vector bookmarks_; + float autoSaveTimer_ = 0.0f; + float autoSaveInterval_ = 300.0f; // 5 minutes + bool autoSaveEnabled_ = true; size_t lastObjectCount_ = 0; EditorMode mode_ = EditorMode::Sculpt; float waterHeight_ = 100.0f; diff --git a/tools/editor/editor_brush.hpp b/tools/editor/editor_brush.hpp index 38f273d7..f9fef177 100644 --- a/tools/editor/editor_brush.hpp +++ b/tools/editor/editor_brush.hpp @@ -10,7 +10,8 @@ enum class BrushMode { Lower, Smooth, Flatten, - Level + Level, + Erode }; struct BrushSettings { diff --git a/tools/editor/editor_ui.cpp b/tools/editor/editor_ui.cpp index 7d45737f..e2a9a241 100644 --- a/tools/editor/editor_ui.cpp +++ b/tools/editor/editor_ui.cpp @@ -222,9 +222,9 @@ void EditorUI::renderBrushPanel(EditorApp& app) { ImGui::End(); return; } auto& s = app.getTerrainEditor().brush().settings(); - const char* modes[] = {"Raise", "Lower", "Smooth", "Flatten", "Level"}; + const char* modes[] = {"Raise", "Lower", "Smooth", "Flatten", "Level", "Erode"}; int idx = static_cast(s.mode); - if (ImGui::Combo("Mode", &idx, modes, 5)) s.mode = static_cast(idx); + if (ImGui::Combo("Mode", &idx, modes, 6)) s.mode = static_cast(idx); ImGui::SliderFloat("Radius", &s.radius, 5.0f, 200.0f, "%.0f"); ImGui::SliderFloat("Strength", &s.strength, 0.5f, 50.0f, "%.1f"); ImGui::SliderFloat("Falloff", &s.falloff, 0.0f, 1.0f, "%.2f"); @@ -701,7 +701,12 @@ void EditorUI::renderNpcPanel(EditorApp& app) { ImGui::Separator(); static char npcPath[256] = "output/creatures.json"; ImGui::InputText("File##npc", npcPath, sizeof(npcPath)); - if (ImGui::Button("Save NPCs")) spawner.saveToFile(npcPath); + if (ImGui::Button("Save NPCs", ImVec2(100, 0))) spawner.saveToFile(npcPath); + ImGui::SameLine(); + if (ImGui::Button("Load NPCs", ImVec2(100, 0))) { + spawner.loadFromFile(npcPath); + app.markObjectsDirty(); + } ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1), "Click terrain to place selected creature"); diff --git a/tools/editor/npc_spawner.cpp b/tools/editor/npc_spawner.cpp index 2d5932b9..4ea29e6a 100644 --- a/tools/editor/npc_spawner.cpp +++ b/tools/editor/npc_spawner.cpp @@ -121,9 +121,101 @@ void NpcSpawner::scatter(const CreatureSpawn& base, const glm::vec3& center, } 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); - return false; + std::ifstream f(path); + if (!f) { LOG_ERROR("Failed to open NPC file: ", path); return false; } + + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + // Minimal JSON parser — extract fields from our known format + spawns_.clear(); + selectedIdx_ = -1; + + auto findStr = [&](const std::string& block, const std::string& key) -> std::string { + auto pos = block.find("\"" + key + "\""); + if (pos == std::string::npos) return ""; + pos = block.find(':', pos); + if (pos == std::string::npos) return ""; + pos = block.find('"', pos + 1); + if (pos == std::string::npos) return ""; + auto end = block.find('"', pos + 1); + if (end == std::string::npos) return ""; + return block.substr(pos + 1, end - pos - 1); + }; + + auto findNum = [&](const std::string& block, const std::string& key) -> float { + auto pos = block.find("\"" + key + "\""); + if (pos == std::string::npos) return 0; + pos = block.find(':', pos); + if (pos == std::string::npos) return 0; + return std::stof(block.substr(pos + 1)); + }; + + auto findBool = [&](const std::string& block, const std::string& key) -> bool { + auto pos = block.find("\"" + key + "\""); + if (pos == std::string::npos) return false; + return block.find("true", pos) < block.find('\n', pos); + }; + + // Split by object boundaries + size_t start = 0; + while ((start = content.find('{', start)) != std::string::npos) { + auto end = content.find('}', start); + if (end == std::string::npos) break; + std::string block = content.substr(start, end - start + 1); + + CreatureSpawn s; + s.name = findStr(block, "name"); + s.modelPath = findStr(block, "model"); + s.displayId = static_cast(findNum(block, "displayId")); + s.orientation = findNum(block, "orientation"); + s.scale = findNum(block, "scale"); + if (s.scale < 0.1f) s.scale = 1.0f; + s.level = static_cast(std::max(1.0f, findNum(block, "level"))); + s.health = static_cast(std::max(1.0f, findNum(block, "health"))); + s.mana = static_cast(findNum(block, "mana")); + s.minDamage = static_cast(findNum(block, "minDamage")); + s.maxDamage = static_cast(findNum(block, "maxDamage")); + s.armor = static_cast(findNum(block, "armor")); + s.faction = static_cast(findNum(block, "faction")); + s.behavior = static_cast(static_cast(findNum(block, "behavior"))); + s.wanderRadius = findNum(block, "wanderRadius"); + s.aggroRadius = findNum(block, "aggroRadius"); + s.leashRadius = findNum(block, "leashRadius"); + s.respawnTimeMs = static_cast(findNum(block, "respawnTimeMs")); + s.hostile = findBool(block, "hostile"); + s.questgiver = findBool(block, "questgiver"); + s.vendor = findBool(block, "vendor"); + s.flightmaster = findBool(block, "flightmaster"); + s.innkeeper = findBool(block, "innkeeper"); + + // Parse position array + auto posStart = block.find("\"position\""); + if (posStart != std::string::npos) { + auto bk = block.find('[', posStart); + if (bk != std::string::npos) { + float vals[3] = {}; + int vi = 0; + auto p = bk + 1; + while (vi < 3 && p < block.size()) { + vals[vi++] = std::stof(block.substr(p)); + p = block.find(',', p); + if (p == std::string::npos) break; + p++; + } + s.position = glm::vec3(vals[0], vals[1], vals[2]); + } + } + + if (!s.name.empty()) { + s.id = nextId(); + spawns_.push_back(s); + } + start = end + 1; + } + + LOG_INFO("NPC spawns loaded: ", path, " (", spawns_.size(), " creatures)"); + return true; } } // namespace editor diff --git a/tools/editor/terrain_editor.cpp b/tools/editor/terrain_editor.cpp index 4e048b7d..9c8b780a 100644 --- a/tools/editor/terrain_editor.cpp +++ b/tools/editor/terrain_editor.cpp @@ -252,6 +252,7 @@ void TerrainEditor::applyBrush(float deltaTime) { case BrushMode::Smooth: applySmooth(deltaTime); break; case BrushMode::Flatten: case BrushMode::Level: applyFlatten(deltaTime); break; + case BrushMode::Erode: applyErode(deltaTime); break; } } @@ -586,6 +587,50 @@ void TerrainEditor::removeWater(const glm::vec3& center, float radius) { } } +void TerrainEditor::applyErode(float dt) { + float factor = std::min(1.0f, brush_.settings().strength * dt * 0.3f); + + auto affected = getAffectedChunks(brush_.getPosition(), brush_.settings().radius); + for (int chunkIdx : affected) { + bool modified = false; + auto& chunk = terrain_->chunks[chunkIdx]; + for (int v = 0; v < 145; v++) { + glm::vec3 pos = chunkVertexWorldPos(chunkIdx, v); + float dist = glm::length(glm::vec2(pos.x - brush_.getPosition().x, + pos.y - brush_.getPosition().y)); + float influence = brush_.getInfluence(dist); + if (influence <= 0.0f) continue; + + float h = chunk.heightMap.heights[v]; + int row = v / 17, col = v % 17; + + // Find lowest neighbor (same chunk) + float lowestH = h; + if (col <= 8) { + int neighbors[] = {v - 17, v + 17, v - 1, v + 1}; + for (int n : neighbors) { + if (n >= 0 && n < 145) + lowestH = std::min(lowestH, chunk.heightMap.heights[n]); + } + } + + // Move height toward lowest neighbor (erosion) + float slope = h - lowestH; + if (slope > 0.1f) { + float erosion = slope * factor * influence * 0.3f; + chunk.heightMap.heights[v] -= erosion; + modified = true; + } + } + if (modified) { + stitchEdges(chunkIdx); + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } + } +} + void TerrainEditor::applyNoise(float frequency, float amplitude, int octaves, uint32_t seed) { if (!terrain_) return; diff --git a/tools/editor/terrain_editor.hpp b/tools/editor/terrain_editor.hpp index ad381449..bf95ac0d 100644 --- a/tools/editor/terrain_editor.hpp +++ b/tools/editor/terrain_editor.hpp @@ -69,6 +69,7 @@ private: void applyRaise(float dt); void applySmooth(float dt); void applyFlatten(float dt); + void applyErode(float dt); void stitchEdges(int chunkIdx); std::vector getAffectedChunks(const glm::vec3& center, float radius) const;