feat(editor): quest editor with objectives, rewards, and quest chains

- New Quest mode (key 6) with full quest creation panel:
  - Title, description, required level
  - Quest giver / turn-in NPC ID linkage
  - Up to 4 objectives: Kill, Collect, Talk, Explore, Escort, Use Object
  - Rewards: XP and gold
  - Quest chain support via nextQuestId linking
- Quest list showing all created quests with level and objective count
- Save quests to JSON (included in Export Zone package)
- Foundation for campaign system: create quest chains across NPCs,
  link objectives to placed creatures, build storylines
This commit is contained in:
Kelsi 2026-05-05 06:10:14 -07:00
parent 124ff5a54a
commit f59d79537a
7 changed files with 239 additions and 2 deletions

View file

@ -660,6 +660,12 @@ void EditorApp::exportZone(const std::string& outputDir) {
npcSpawner_.saveToFile(npcPath);
}
// Save quests
if (questEditor_.questCount() > 0) {
std::string questPath = base + "/quests.json";
questEditor_.saveToFile(questPath);
}
// Save placed objects
if (objectPlacer_.objectCount() > 0) {
std::string objPath = base + "/objects.json";

View file

@ -8,6 +8,7 @@
#include "object_placer.hpp"
#include "npc_spawner.hpp"
#include "npc_presets.hpp"
#include "quest_editor.hpp"
#include "zone_manifest.hpp"
#include "asset_browser.hpp"
#include "core/window.hpp"
@ -18,7 +19,7 @@
namespace wowee {
namespace editor {
enum class EditorMode { Sculpt, Paint, PlaceObject, Water, NPC };
enum class EditorMode { Sculpt, Paint, PlaceObject, Water, NPC, Quest };
class EditorApp {
public:
@ -47,6 +48,7 @@ public:
ObjectPlacer& getObjectPlacer() { return objectPlacer_; }
NpcSpawner& getNpcSpawner() { return npcSpawner_; }
NpcPresets& getNpcPresets() { return npcPresets_; }
QuestEditor& getQuestEditor() { return questEditor_; }
AssetBrowser& getAssetBrowser() { return assetBrowser_; }
rendering::TerrainRenderer* getTerrainRenderer();
rendering::M2Renderer* getM2Renderer() { return viewport_.getM2Renderer(); }
@ -110,6 +112,7 @@ private:
ObjectPlacer objectPlacer_;
NpcSpawner npcSpawner_;
NpcPresets npcPresets_;
QuestEditor questEditor_;
AssetBrowser assetBrowser_;
pipeline::ADTTerrain terrain_;

View file

@ -5,6 +5,7 @@
#include "object_placer.hpp"
#include "npc_spawner.hpp"
#include "npc_presets.hpp"
#include "quest_editor.hpp"
#include "asset_browser.hpp"
#include "transform_gizmo.hpp"
#include "terrain_biomes.hpp"
@ -42,6 +43,7 @@ void EditorUI::render(EditorApp& app) {
case EditorMode::PlaceObject: renderObjectPanel(app); break;
case EditorMode::Water: renderWaterPanel(app); break;
case EditorMode::NPC: renderNpcPanel(app); break;
case EditorMode::Quest: renderQuestPanel(app); break;
}
renderContextMenu(app);
@ -271,6 +273,7 @@ void EditorUI::renderToolbar(EditorApp& app) {
modeButton("Objects", EditorMode::PlaceObject);
modeButton("Water", EditorMode::Water);
modeButton("NPCs", EditorMode::NPC);
modeButton("Quests", EditorMode::Quest);
}
ImGui::End();
ImGui::PopStyleVar();
@ -1010,6 +1013,94 @@ void EditorUI::renderNpcPanel(EditorApp& app) {
ImGui::End();
}
void EditorUI::renderQuestPanel(EditorApp& app) {
ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->Size.x - 400, 90), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(390, 600), ImGuiCond_FirstUseEver);
if (ImGui::Begin("Quest Editor")) {
auto& qe = app.getQuestEditor();
auto& tmpl = qe.getTemplate();
if (ImGui::CollapsingHeader("New Quest", ImGuiTreeNodeFlags_DefaultOpen)) {
static char titleBuf[128] = "New Quest";
std::strncpy(titleBuf, tmpl.title.c_str(), sizeof(titleBuf) - 1);
if (ImGui::InputText("Title##q", titleBuf, sizeof(titleBuf)))
tmpl.title = titleBuf;
static char descBuf[512] = "";
std::strncpy(descBuf, tmpl.description.c_str(), sizeof(descBuf) - 1);
if (ImGui::InputTextMultiline("Description##q", descBuf, sizeof(descBuf), ImVec2(-1, 60)))
tmpl.description = descBuf;
int lvl = tmpl.requiredLevel;
if (ImGui::InputInt("Required Level", &lvl)) tmpl.requiredLevel = std::max(1, lvl);
int giver = tmpl.questGiverNpcId;
if (ImGui::InputInt("Giver NPC ID", &giver)) tmpl.questGiverNpcId = std::max(0, giver);
int turnin = tmpl.turnInNpcId;
if (ImGui::InputInt("Turn-in NPC ID", &turnin)) tmpl.turnInNpcId = std::max(0, turnin);
ImGui::Separator();
ImGui::Text("Objectives:");
if (tmpl.objectives.size() < 4 && ImGui::Button("Add Objective")) {
QuestObjective obj;
obj.description = "Kill 5 creatures";
tmpl.objectives.push_back(obj);
}
for (int oi = 0; oi < static_cast<int>(tmpl.objectives.size()); oi++) {
auto& obj = tmpl.objectives[oi];
ImGui::PushID(oi);
const char* types[] = {"Kill", "Collect", "Talk", "Explore", "Escort", "Use Object"};
int ti = static_cast<int>(obj.type);
ImGui::Combo("Type", &ti, types, 6);
obj.type = static_cast<QuestObjectiveType>(ti);
static char objDesc[128];
std::strncpy(objDesc, obj.description.c_str(), sizeof(objDesc) - 1);
if (ImGui::InputText("Desc", objDesc, sizeof(objDesc))) obj.description = objDesc;
int cnt = obj.targetCount;
if (ImGui::InputInt("Count", &cnt)) obj.targetCount = std::max(1, cnt);
if (ImGui::SmallButton("Remove")) tmpl.objectives.erase(tmpl.objectives.begin() + oi--);
ImGui::PopID();
ImGui::Separator();
}
ImGui::Text("Rewards:");
int xp = tmpl.reward.xp;
if (ImGui::InputInt("XP##qr", &xp)) tmpl.reward.xp = std::max(0, xp);
int gold = tmpl.reward.gold;
if (ImGui::InputInt("Gold##qr", &gold)) tmpl.reward.gold = std::max(0, gold);
if (ImGui::Button("Create Quest", ImVec2(-1, 0))) {
qe.addQuest(tmpl);
app.showToast("Quest created: " + tmpl.title);
}
}
ImGui::Separator();
if (ImGui::CollapsingHeader("Quest List", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Text("%zu quests", qe.questCount());
ImGui::BeginChild("QuestList", ImVec2(0, 120), true);
for (int i = 0; i < static_cast<int>(qe.questCount()); i++) {
auto* q = qe.getQuest(i);
char lbl[128];
std::snprintf(lbl, sizeof(lbl), "[%u] %s (Lv%u, %zu obj)",
q->id, q->title.c_str(), q->requiredLevel, q->objectives.size());
if (ImGui::Selectable(lbl)) { /* select for editing */ }
}
ImGui::EndChild();
}
ImGui::Separator();
static char questPath[256] = "output/quests.json";
ImGui::InputText("File##quest", questPath, sizeof(questPath));
if (ImGui::Button("Save Quests", ImVec2(-1, 0))) {
qe.saveToFile(questPath);
app.showToast("Quests saved");
}
}
ImGui::End();
}
void EditorUI::renderWaterPanel(EditorApp& app) {
ImGui::SetNextWindowPos(ImVec2(10, 90), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(280, 250), ImGuiCond_FirstUseEver);
@ -1293,7 +1384,7 @@ void EditorUI::renderStatusBar(EditorApp& app) {
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing;
if (ImGui::Begin("##StatusBar", nullptr, flags)) {
const char* ms[] = {"Sculpt", "Paint", "Objects", "Water", "NPCs"};
const char* ms[] = {"Sculpt", "Paint", "Objects", "Water", "NPCs", "Quests"};
const char* m = ms[static_cast<int>(app.getMode())];
if (app.hasTerrainLoaded()) {
ImGui::Text("[%s] %s [%d,%d]%s", m, app.getLoadedMap().c_str(),

View file

@ -31,6 +31,7 @@ private:
void renderObjectPanel(EditorApp& app);
void renderWaterPanel(EditorApp& app);
void renderNpcPanel(EditorApp& app);
void renderQuestPanel(EditorApp& app);
void renderContextMenu(EditorApp& app);
void renderMinimap(EditorApp& app);
void renderPropertiesPanel(EditorApp& app);

View file

@ -0,0 +1,68 @@
#include "quest_editor.hpp"
#include "core/logger.hpp"
#include <fstream>
#include <filesystem>
namespace wowee {
namespace editor {
void QuestEditor::addQuest(const Quest& q) {
Quest quest = q;
quest.id = nextId_++;
quests_.push_back(quest);
LOG_INFO("Quest added: [", quest.id, "] ", quest.title);
}
void QuestEditor::removeQuest(int index) {
if (index >= 0 && index < static_cast<int>(quests_.size()))
quests_.erase(quests_.begin() + index);
}
Quest* QuestEditor::getQuest(int index) {
if (index < 0 || index >= static_cast<int>(quests_.size())) return nullptr;
return &quests_[index];
}
bool QuestEditor::saveToFile(const std::string& path) const {
auto dir = std::filesystem::path(path).parent_path();
if (!dir.empty()) std::filesystem::create_directories(dir);
std::ofstream f(path);
if (!f) return false;
f << "[\n";
for (size_t i = 0; i < quests_.size(); i++) {
const auto& q = quests_[i];
f << " {\n";
f << " \"id\": " << q.id << ",\n";
f << " \"title\": \"" << q.title << "\",\n";
f << " \"description\": \"" << q.description << "\",\n";
f << " \"completionText\": \"" << q.completionText << "\",\n";
f << " \"requiredLevel\": " << q.requiredLevel << ",\n";
f << " \"questGiverNpcId\": " << q.questGiverNpcId << ",\n";
f << " \"turnInNpcId\": " << q.turnInNpcId << ",\n";
f << " \"nextQuestId\": " << q.nextQuestId << ",\n";
f << " \"reward\": {\"xp\":" << q.reward.xp
<< ",\"gold\":" << q.reward.gold
<< ",\"silver\":" << q.reward.silver
<< ",\"copper\":" << q.reward.copper << "},\n";
f << " \"objectives\": [";
for (size_t j = 0; j < q.objectives.size(); j++) {
const auto& obj = q.objectives[j];
f << "{\"type\":" << static_cast<int>(obj.type)
<< ",\"desc\":\"" << obj.description << "\""
<< ",\"target\":\"" << obj.targetName << "\""
<< ",\"count\":" << obj.targetCount << "}";
if (j + 1 < q.objectives.size()) f << ",";
}
f << "]\n";
f << " }" << (i + 1 < quests_.size() ? "," : "") << "\n";
}
f << "]\n";
LOG_INFO("Quests saved: ", path, " (", quests_.size(), " quests)");
return true;
}
} // namespace editor
} // namespace wowee

View file

@ -0,0 +1,67 @@
#pragma once
#include <glm/glm.hpp>
#include <string>
#include <vector>
#include <cstdint>
namespace wowee {
namespace editor {
enum class QuestObjectiveType {
KillCreature,
CollectItem,
TalkToNPC,
ExploreArea,
EscortNPC,
UseObject
};
struct QuestObjective {
QuestObjectiveType type = QuestObjectiveType::KillCreature;
std::string description;
std::string targetName;
uint32_t targetCount = 1;
};
struct QuestReward {
uint32_t xp = 100;
uint32_t gold = 0;
uint32_t silver = 0;
uint32_t copper = 0;
std::vector<std::string> itemRewards;
};
struct Quest {
uint32_t id = 0;
std::string title = "New Quest";
std::string description;
std::string completionText;
uint32_t requiredLevel = 1;
uint32_t questGiverNpcId = 0;
uint32_t turnInNpcId = 0;
std::vector<QuestObjective> objectives;
QuestReward reward;
uint32_t nextQuestId = 0; // chain link
};
class QuestEditor {
public:
void addQuest(const Quest& q);
void removeQuest(int index);
Quest* getQuest(int index);
const std::vector<Quest>& getQuests() const { return quests_; }
size_t questCount() const { return quests_.size(); }
bool saveToFile(const std::string& path) const;
Quest& getTemplate() { return template_; }
private:
std::vector<Quest> quests_;
Quest template_;
uint32_t nextId_ = 1;
};
} // namespace editor
} // namespace wowee