#include "npc_spawner.hpp" #include "core/logger.hpp" #include #include #include #include #include #include #include namespace wowee { namespace editor { uint32_t NpcSpawner::nextId() { return idCounter_++; } void NpcSpawner::placeCreature(const CreatureSpawn& spawn) { CreatureSpawn s = spawn; s.id = nextId(); s.selected = false; spawns_.push_back(s); LOG_INFO("Creature placed: ", s.name, " (id=", s.id, ") at (", s.position.x, ",", s.position.y, ",", s.position.z, ")"); } void NpcSpawner::removeCreature(int index) { if (index < 0 || index >= static_cast(spawns_.size())) return; spawns_.erase(spawns_.begin() + index); if (selectedIdx_ == index) selectedIdx_ = -1; else if (selectedIdx_ > index) selectedIdx_--; } int NpcSpawner::selectAt(const glm::vec3& worldPos, float maxDist) { clearSelection(); float bestDist = maxDist; int bestIdx = -1; for (int i = 0; i < static_cast(spawns_.size()); i++) { float dist = glm::length(spawns_[i].position - worldPos); if (dist < bestDist) { bestDist = dist; bestIdx = i; } } if (bestIdx >= 0) { selectedIdx_ = bestIdx; spawns_[bestIdx].selected = true; } return bestIdx; } void NpcSpawner::clearSelection() { if (selectedIdx_ >= 0 && selectedIdx_ < static_cast(spawns_.size())) spawns_[selectedIdx_].selected = false; selectedIdx_ = -1; } CreatureSpawn* NpcSpawner::getSelected() { if (selectedIdx_ < 0 || selectedIdx_ >= static_cast(spawns_.size())) return nullptr; return &spawns_[selectedIdx_]; } bool NpcSpawner::saveToFile(const std::string& path) const { auto dir = std::filesystem::path(path).parent_path(); if (!dir.empty()) std::filesystem::create_directories(dir); nlohmann::json arr = nlohmann::json::array(); for (const auto& s : spawns_) { nlohmann::json js; js["id"] = s.id; js["name"] = s.name; js["model"] = s.modelPath; js["displayId"] = s.displayId; js["position"] = {s.position.x, s.position.y, s.position.z}; js["orientation"] = s.orientation; js["scale"] = s.scale; js["level"] = s.level; js["health"] = s.health; js["mana"] = s.mana; js["minDamage"] = s.minDamage; js["maxDamage"] = s.maxDamage; js["armor"] = s.armor; js["faction"] = s.faction; js["behavior"] = static_cast(s.behavior); js["wanderRadius"] = s.wanderRadius; js["aggroRadius"] = s.aggroRadius; js["leashRadius"] = s.leashRadius; js["respawnTimeMs"] = s.respawnTimeMs; js["hostile"] = s.hostile; js["questgiver"] = s.questgiver; js["vendor"] = s.vendor; js["flightmaster"] = s.flightmaster; js["innkeeper"] = s.innkeeper; js["trainer"] = s.trainer; js["auctioneer"] = s.auctioneer; js["banker"] = s.banker; js["repair"] = s.repair; nlohmann::json patrol = nlohmann::json::array(); for (const auto& p : s.patrolPath) { patrol.push_back({p.position.x, p.position.y, p.position.z, p.waitTimeMs}); } js["patrol"] = patrol; arr.push_back(js); } std::ofstream f(path); if (!f) { LOG_ERROR("Failed to write NPC file: ", path); return false; } f << arr.dump(2) << "\n"; LOG_INFO("NPC spawns saved: ", path, " (", spawns_.size(), " creatures)"); return true; } void NpcSpawner::scatter(const CreatureSpawn& base, const glm::vec3& center, float radius, int count) { std::mt19937 rng(static_cast(center.x * 100 + center.y * 37)); std::uniform_real_distribution distAngle(0.0f, 6.2831853f); std::uniform_real_distribution distDist(0.0f, radius); std::uniform_real_distribution 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) { std::ifstream f(path); if (!f) { LOG_ERROR("Failed to open NPC file: ", path); return false; } try { auto arr = nlohmann::json::parse(f); if (!arr.is_array()) return false; spawns_.clear(); selectedIdx_ = -1; idCounter_ = 1; for (const auto& js : arr) { CreatureSpawn s; s.name = js.value("name", ""); s.modelPath = js.value("model", ""); s.displayId = js.value("displayId", 0u); s.orientation = js.value("orientation", 0.0f); // Normalise orientation to [0, 360) for consistent gizmo behaviour. if (std::isfinite(s.orientation)) { s.orientation = std::fmod(s.orientation, 360.0f); if (s.orientation < 0.0f) s.orientation += 360.0f; } else { s.orientation = 0.0f; } s.scale = js.value("scale", 1.0f); if (!std::isfinite(s.scale) || s.scale < 0.1f) s.scale = 1.0f; s.level = js.value("level", 1u); // WoW level cap is 80 (WotLK) but allow up to 255 for special // bosses; 0 is invalid. if (s.level == 0) s.level = 1; if (s.level > 255) s.level = 255; s.health = js.value("health", 100u); if (s.health == 0) s.health = 1; s.mana = js.value("mana", 0u); s.minDamage = js.value("minDamage", 5u); s.maxDamage = js.value("maxDamage", 10u); // maxDmg should be >= minDmg. if (s.maxDamage < s.minDamage) s.maxDamage = s.minDamage; s.armor = js.value("armor", 0u); s.faction = js.value("faction", 0u); int beh = js.value("behavior", 0); if (beh < 0 || beh > 3) beh = 0; s.behavior = static_cast(beh); s.wanderRadius = js.value("wanderRadius", 0.0f); if (!std::isfinite(s.wanderRadius) || s.wanderRadius < 0.0f) s.wanderRadius = 0.0f; if (s.wanderRadius > 1000.0f) s.wanderRadius = 1000.0f; s.aggroRadius = js.value("aggroRadius", 15.0f); if (!std::isfinite(s.aggroRadius) || s.aggroRadius < 0.0f) s.aggroRadius = 0.0f; s.leashRadius = js.value("leashRadius", 40.0f); if (!std::isfinite(s.leashRadius) || s.leashRadius < 0.0f) s.leashRadius = 40.0f; s.respawnTimeMs = js.value("respawnTimeMs", 60000u); // Cap respawn at 24h — typo guard, also matches AzerothCore. if (s.respawnTimeMs > 86'400'000u) s.respawnTimeMs = 86'400'000u; if (s.respawnTimeMs < 1000u) s.respawnTimeMs = 1000u; s.hostile = js.value("hostile", false); s.questgiver = js.value("questgiver", false); s.vendor = js.value("vendor", false); s.flightmaster = js.value("flightmaster", false); s.innkeeper = js.value("innkeeper", false); s.trainer = js.value("trainer", false); s.auctioneer = js.value("auctioneer", false); s.banker = js.value("banker", false); s.repair = js.value("repair", false); if (js.contains("position") && js["position"].is_array() && js["position"].size() >= 3) { s.position = glm::vec3(js["position"][0].get(), js["position"][1].get(), js["position"][2].get()); // Reject NaN/inf positions — they crash the M2 renderer's // matrix math and produce invisible / chaos-shaped instances. if (!std::isfinite(s.position.x) || !std::isfinite(s.position.y) || !std::isfinite(s.position.z)) { s.position = glm::vec3(0.0f); } } if (js.contains("patrol") && js["patrol"].is_array()) { for (const auto& pt : js["patrol"]) { if (pt.is_array() && pt.size() >= 4) { PatrolPoint pp; pp.position = glm::vec3(pt[0].get(), pt[1].get(), pt[2].get()); // Skip waypoints with NaN/inf — would produce a path // that warps the creature to garbage coords. if (!std::isfinite(pp.position.x) || !std::isfinite(pp.position.y) || !std::isfinite(pp.position.z)) continue; pp.waitTimeMs = pt[3].get(); // Cap wait time at 10 minutes to keep AzerothCore // happy and prevent obvious data-entry typos that // would produce a creature that effectively never // moves (e.g. 24h = 86400000). if (pp.waitTimeMs > 600000) pp.waitTimeMs = 600000; s.patrolPath.push_back(pp); } } } if (!s.name.empty()) { // Preserve original id from JSON if present so quest hooks // (questGiverNpcId, turnInNpcId, KillCreature targetName) // remain stable across save/load. Bump idCounter past any // loaded value to avoid collisions with future placements. if (js.contains("id")) { s.id = js["id"].get(); if (s.id >= idCounter_) idCounter_ = s.id + 1; } else { s.id = nextId(); } spawns_.push_back(s); } } LOG_INFO("NPC spawns loaded: ", path, " (", spawns_.size(), " creatures)"); return true; } catch (const std::exception& e) { LOG_ERROR("Failed to parse NPC file: ", e.what()); return false; } } } // namespace editor } // namespace wowee