mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-03 08:03:50 +00:00
Fix quest log titles and full-row selection behavior
This commit is contained in:
parent
cabf897683
commit
334d4d3df6
4 changed files with 417 additions and 126 deletions
|
|
@ -707,6 +707,7 @@ public:
|
||||||
};
|
};
|
||||||
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
|
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
|
||||||
void abandonQuest(uint32_t questId);
|
void abandonQuest(uint32_t questId);
|
||||||
|
bool requestQuestQuery(uint32_t questId, bool force = false);
|
||||||
const std::unordered_map<uint32_t, uint32_t>& getWorldStates() const { return worldStates_; }
|
const std::unordered_map<uint32_t, uint32_t>& getWorldStates() const { return worldStates_; }
|
||||||
std::optional<uint32_t> getWorldState(uint32_t key) const {
|
std::optional<uint32_t> getWorldState(uint32_t key) const {
|
||||||
auto it = worldStates_.find(key);
|
auto it = worldStates_.find(key);
|
||||||
|
|
@ -1435,6 +1436,7 @@ private:
|
||||||
|
|
||||||
// Quest log
|
// Quest log
|
||||||
std::vector<QuestLogEntry> questLog_;
|
std::vector<QuestLogEntry> questLog_;
|
||||||
|
std::unordered_set<uint32_t> pendingQuestQueryIds_;
|
||||||
|
|
||||||
// Quest giver status per NPC
|
// Quest giver status per NPC
|
||||||
std::unordered_map<uint64_t, QuestGiverStatus> npcQuestStatus_;
|
std::unordered_map<uint64_t, QuestGiverStatus> npcQuestStatus_;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "game/game_handler.hpp"
|
#include "game/game_handler.hpp"
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
namespace wowee { namespace ui {
|
namespace wowee { namespace ui {
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ private:
|
||||||
bool open = false;
|
bool open = false;
|
||||||
bool lKeyWasDown = false;
|
bool lKeyWasDown = false;
|
||||||
int selectedIndex = -1;
|
int selectedIndex = -1;
|
||||||
|
uint32_t lastDetailRequestQuestId_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
}} // namespace wowee::ui
|
}} // namespace wowee::ui
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,134 @@ std::string formatCopperAmount(uint32_t amount) {
|
||||||
}
|
}
|
||||||
return oss.str();
|
return oss.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool readCStringAt(const std::vector<uint8_t>& data, size_t start, std::string& out, size_t& nextPos) {
|
||||||
|
out.clear();
|
||||||
|
if (start >= data.size()) return false;
|
||||||
|
size_t i = start;
|
||||||
|
while (i < data.size()) {
|
||||||
|
uint8_t b = data[i++];
|
||||||
|
if (b == 0) {
|
||||||
|
nextPos = i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
out.push_back(static_cast<char>(b));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isReadableQuestText(const std::string& s, size_t minLen, size_t maxLen) {
|
||||||
|
if (s.size() < minLen || s.size() > maxLen) return false;
|
||||||
|
bool hasAlpha = false;
|
||||||
|
for (unsigned char c : s) {
|
||||||
|
if (c < 0x20 || c > 0x7E) return false;
|
||||||
|
if (std::isalpha(c)) hasAlpha = true;
|
||||||
|
}
|
||||||
|
return hasAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPlaceholderQuestTitle(const std::string& s) {
|
||||||
|
return s.rfind("Quest #", 0) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool looksLikeQuestDescriptionText(const std::string& s) {
|
||||||
|
int spaces = 0;
|
||||||
|
int commas = 0;
|
||||||
|
for (unsigned char c : s) {
|
||||||
|
if (c == ' ') spaces++;
|
||||||
|
if (c == ',') commas++;
|
||||||
|
}
|
||||||
|
const int words = spaces + 1;
|
||||||
|
if (words > 8) return true;
|
||||||
|
if (commas > 0 && words > 5) return true;
|
||||||
|
if (s.find(". ") != std::string::npos) return true;
|
||||||
|
if (s.find(':') != std::string::npos && words > 5) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStrongQuestTitle(const std::string& s) {
|
||||||
|
if (!isReadableQuestText(s, 6, 72)) return false;
|
||||||
|
if (looksLikeQuestDescriptionText(s)) return false;
|
||||||
|
unsigned char first = static_cast<unsigned char>(s.front());
|
||||||
|
return std::isupper(first) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scoreQuestTitle(const std::string& s) {
|
||||||
|
if (!isReadableQuestText(s, 4, 72)) return -1000;
|
||||||
|
if (looksLikeQuestDescriptionText(s)) return -1000;
|
||||||
|
int score = 0;
|
||||||
|
score += static_cast<int>(std::min<size_t>(s.size(), 32));
|
||||||
|
unsigned char first = static_cast<unsigned char>(s.front());
|
||||||
|
if (std::isupper(first)) score += 20;
|
||||||
|
if (std::islower(first)) score -= 20;
|
||||||
|
if (s.find(' ') != std::string::npos) score += 8;
|
||||||
|
if (s.find('.') != std::string::npos) score -= 18;
|
||||||
|
if (s.find('!') != std::string::npos || s.find('?') != std::string::npos) score -= 6;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuestQueryTextCandidate {
|
||||||
|
std::string title;
|
||||||
|
std::string objectives;
|
||||||
|
int score = -1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector<uint8_t>& data, bool classicHint) {
|
||||||
|
QuestQueryTextCandidate best;
|
||||||
|
if (data.size() <= 9) return best;
|
||||||
|
|
||||||
|
std::vector<size_t> seedOffsets;
|
||||||
|
const size_t base = 8;
|
||||||
|
const size_t classicOffset = base + 40u * 4u;
|
||||||
|
const size_t wotlkOffset = base + 55u * 4u;
|
||||||
|
if (classicHint) {
|
||||||
|
seedOffsets.push_back(classicOffset);
|
||||||
|
seedOffsets.push_back(wotlkOffset);
|
||||||
|
} else {
|
||||||
|
seedOffsets.push_back(wotlkOffset);
|
||||||
|
seedOffsets.push_back(classicOffset);
|
||||||
|
}
|
||||||
|
for (size_t off : seedOffsets) {
|
||||||
|
if (off < data.size()) {
|
||||||
|
std::string title;
|
||||||
|
size_t next = off;
|
||||||
|
if (readCStringAt(data, off, title, next)) {
|
||||||
|
QuestQueryTextCandidate c;
|
||||||
|
c.title = title;
|
||||||
|
c.score = scoreQuestTitle(title) + 20; // Prefer expected struct offsets
|
||||||
|
|
||||||
|
std::string s2;
|
||||||
|
size_t n2 = next;
|
||||||
|
if (readCStringAt(data, next, s2, n2) && isReadableQuestText(s2, 8, 600)) {
|
||||||
|
c.objectives = s2;
|
||||||
|
}
|
||||||
|
if (c.score > best.score) best = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scan packet for best printable C-string title candidate.
|
||||||
|
for (size_t start = 8; start < data.size(); ++start) {
|
||||||
|
std::string title;
|
||||||
|
size_t next = start;
|
||||||
|
if (!readCStringAt(data, start, title, next)) continue;
|
||||||
|
|
||||||
|
QuestQueryTextCandidate c;
|
||||||
|
c.title = title;
|
||||||
|
c.score = scoreQuestTitle(title);
|
||||||
|
if (c.score < 0) continue;
|
||||||
|
|
||||||
|
std::string s2, s3;
|
||||||
|
size_t n2 = next, n3 = next;
|
||||||
|
if (readCStringAt(data, next, s2, n2)) {
|
||||||
|
if (isReadableQuestText(s2, 8, 600)) c.objectives = s2;
|
||||||
|
else if (readCStringAt(data, n2, s3, n3) && isReadableQuestText(s3, 8, 600)) c.objectives = s3;
|
||||||
|
}
|
||||||
|
if (c.score > best.score) best = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1764,6 +1892,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
uint32_t questId = packet.readUInt32();
|
uint32_t questId = packet.readUInt32();
|
||||||
|
pendingQuestQueryIds_.erase(questId);
|
||||||
if (questId == 0) {
|
if (questId == 0) {
|
||||||
// Some servers emit a zero-id variant during world bootstrap.
|
// Some servers emit a zero-id variant during world bootstrap.
|
||||||
// Treat as no-op to avoid false "Quest removed" spam.
|
// Treat as no-op to avoid false "Quest removed" spam.
|
||||||
|
|
@ -1805,75 +1934,43 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
|
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
|
||||||
// Quest data from server (big packet with title, objectives, rewards, etc.)
|
|
||||||
LOG_INFO("SMSG_QUEST_QUERY_RESPONSE: packet size=", packet.getSize());
|
|
||||||
|
|
||||||
if (packet.getSize() < 8) {
|
if (packet.getSize() < 8) {
|
||||||
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t questId = packet.readUInt32();
|
uint32_t questId = packet.readUInt32();
|
||||||
uint32_t questMethod = packet.readUInt32();
|
packet.readUInt32(); // questMethod
|
||||||
|
|
||||||
LOG_INFO(" questId=", questId, " questMethod=", questMethod);
|
|
||||||
|
|
||||||
// SMSG_QUEST_QUERY_RESPONSE layout varies by expansion.
|
|
||||||
//
|
|
||||||
// Classic/Turtle (1.12.x) after questId+questMethod:
|
|
||||||
// 16 header uint32s (questLevel, zoneOrSort, type, suggestedPlayers,
|
|
||||||
// repFaction, repValue, nextChain, xpId,
|
|
||||||
// rewMoney, rewMoneyMax, rewSpell, rewSpellCast,
|
|
||||||
// rewHonor, rewHonorMult, srcItemId, questFlags)
|
|
||||||
// 8 reward items (4 slots × 2: itemId + count)
|
|
||||||
// 12 choice items (6 slots × 2: itemId + count)
|
|
||||||
// 4 POI uint32s (mapId, x, y, opt)
|
|
||||||
// = 40 uint32s before title string
|
|
||||||
//
|
|
||||||
// WotLK (3.3.5) after questId+questMethod:
|
|
||||||
// 21 header uint32s (adds minLevel, questInfoId, 2nd repFaction/Value, questFlags2)
|
|
||||||
// 12 reward items (4 slots × 3: itemId + count + displayId)
|
|
||||||
// 18 choice items (6 slots × 3: itemId + count + displayId)
|
|
||||||
// 4 POI uint32s
|
|
||||||
// = 55 uint32s before title string
|
|
||||||
//
|
|
||||||
// Read all numeric fields, then look for the title string.
|
|
||||||
// Using packetParsers_->questLogStride() as expansion discriminator:
|
|
||||||
// stride==3 → Classic layout (40 skips)
|
|
||||||
// stride==5 → WotLK layout (55 skips)
|
|
||||||
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3;
|
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3;
|
||||||
const int skipCount = isClassicLayout ? 40 : 55;
|
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
|
||||||
|
|
||||||
for (int i = 0; i < skipCount; ++i) {
|
for (auto& q : questLog_) {
|
||||||
packet.readUInt32();
|
if (q.questId != questId) continue;
|
||||||
}
|
|
||||||
|
const int existingScore = scoreQuestTitle(q.title);
|
||||||
if (packet.getReadPos() < packet.getSize()) {
|
const bool parsedStrong = isStrongQuestTitle(parsed.title);
|
||||||
std::string title = packet.readString();
|
const bool parsedLongEnough = parsed.title.size() >= 6;
|
||||||
LOG_INFO(" Quest title: '", title, "'");
|
const bool notShorterThanExisting =
|
||||||
|
isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.title.size() + 2 >= q.title.size();
|
||||||
// Only update if we got a non-empty, printable title (guards against
|
const bool shouldReplaceTitle =
|
||||||
// landing in the middle of binary reward data on wrong layouts).
|
parsed.score > -1000 &&
|
||||||
bool validTitle = !title.empty();
|
parsedStrong &&
|
||||||
if (validTitle) {
|
parsedLongEnough &&
|
||||||
for (char c : title) {
|
notShorterThanExisting &&
|
||||||
if ((unsigned char)c < 0x20 && c != '\t') { validTitle = false; break; }
|
(isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.score >= existingScore + 12);
|
||||||
}
|
|
||||||
}
|
if (shouldReplaceTitle && !parsed.title.empty()) {
|
||||||
|
q.title = parsed.title;
|
||||||
if (validTitle) {
|
}
|
||||||
for (auto& q : questLog_) {
|
if (!parsed.objectives.empty() &&
|
||||||
if (q.questId == questId) {
|
(q.objectives.empty() || parsed.objectives.size() > q.objectives.size())) {
|
||||||
q.title = title;
|
q.objectives = parsed.objectives;
|
||||||
LOG_INFO("Updated quest log entry ", questId, " with title: ", title);
|
}
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_INFO(" Skipping non-printable title (wrong layout?) for quest ", questId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingQuestQueryIds_.erase(questId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Opcode::SMSG_QUESTLOG_FULL: {
|
case Opcode::SMSG_QUESTLOG_FULL: {
|
||||||
|
|
@ -2463,6 +2560,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
|
||||||
hasPlayerExploredZones_ = false;
|
hasPlayerExploredZones_ = false;
|
||||||
playerSkills_.clear();
|
playerSkills_.clear();
|
||||||
questLog_.clear();
|
questLog_.clear();
|
||||||
|
pendingQuestQueryIds_.clear();
|
||||||
npcQuestStatus_.clear();
|
npcQuestStatus_.clear();
|
||||||
hostileAttackers_.clear();
|
hostileAttackers_.clear();
|
||||||
combatText.clear();
|
combatText.clear();
|
||||||
|
|
@ -4157,12 +4255,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
||||||
questLog_.push_back(entry);
|
questLog_.push_back(entry);
|
||||||
LOG_INFO("Found quest in update fields: ", questId);
|
LOG_INFO("Found quest in update fields: ", questId);
|
||||||
|
|
||||||
// Request quest details from server
|
requestQuestQuery(questId);
|
||||||
if (socket) {
|
|
||||||
network::Packet qPkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
|
|
||||||
qPkt.writeUInt32(questId);
|
|
||||||
socket->send(qPkt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4472,11 +4565,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
||||||
entry.title = "Quest #" + std::to_string(qId);
|
entry.title = "Quest #" + std::to_string(qId);
|
||||||
questLog_.push_back(entry);
|
questLog_.push_back(entry);
|
||||||
LOG_INFO("Quest found in VALUES update: ", qId);
|
LOG_INFO("Quest found in VALUES update: ", qId);
|
||||||
if (socket) {
|
requestQuestQuery(qId);
|
||||||
network::Packet qPkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
|
|
||||||
qPkt.writeUInt32(qId);
|
|
||||||
socket->send(qPkt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Quest slot cleared — remove from log if present
|
// Quest slot cleared — remove from log if present
|
||||||
|
|
@ -8579,22 +8668,14 @@ void GameHandler::selectGossipQuest(uint32_t questId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("selectGossipQuest: questId=", questId, " isInLog=", isInLog, " isCompletable=", isCompletable);
|
|
||||||
LOG_INFO(" Current quest log size: ", questLog_.size());
|
|
||||||
for (const auto& q : questLog_) {
|
|
||||||
LOG_INFO(" Quest ", q.questId, ": complete=", q.complete);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInLog && isCompletable) {
|
if (isInLog && isCompletable) {
|
||||||
// Quest is ready to turn in - request reward
|
// Quest is ready to turn in - request reward
|
||||||
LOG_INFO("Turning in quest: questId=", questId, " npcGuid=", currentGossip.npcGuid);
|
|
||||||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD));
|
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD));
|
||||||
packet.writeUInt64(currentGossip.npcGuid);
|
packet.writeUInt64(currentGossip.npcGuid);
|
||||||
packet.writeUInt32(questId);
|
packet.writeUInt32(questId);
|
||||||
socket->send(packet);
|
socket->send(packet);
|
||||||
} else {
|
} else {
|
||||||
// New quest or not completable - query details
|
// New quest or not completable - query details
|
||||||
LOG_INFO("Querying quest details: questId=", questId, " npcGuid=", currentGossip.npcGuid);
|
|
||||||
auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
|
auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
|
||||||
socket->send(packet);
|
socket->send(packet);
|
||||||
}
|
}
|
||||||
|
|
@ -8602,6 +8683,17 @@ void GameHandler::selectGossipQuest(uint32_t questId) {
|
||||||
gossipWindowOpen = false;
|
gossipWindowOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool GameHandler::requestQuestQuery(uint32_t questId, bool force) {
|
||||||
|
if (questId == 0 || state != WorldState::IN_WORLD || !socket) return false;
|
||||||
|
if (!force && pendingQuestQueryIds_.count(questId)) return false;
|
||||||
|
|
||||||
|
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
|
||||||
|
pkt.writeUInt32(questId);
|
||||||
|
socket->send(pkt);
|
||||||
|
pendingQuestQueryIds_.insert(questId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void GameHandler::handleQuestDetails(network::Packet& packet) {
|
void GameHandler::handleQuestDetails(network::Packet& packet) {
|
||||||
QuestDetailsData data;
|
QuestDetailsData data;
|
||||||
bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data)
|
bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data)
|
||||||
|
|
@ -8611,6 +8703,16 @@ void GameHandler::handleQuestDetails(network::Packet& packet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentQuestDetails = data;
|
currentQuestDetails = data;
|
||||||
|
for (auto& q : questLog_) {
|
||||||
|
if (q.questId != data.questId) continue;
|
||||||
|
if (!data.title.empty() && (isPlaceholderQuestTitle(q.title) || data.title.size() >= q.title.size())) {
|
||||||
|
q.title = data.title;
|
||||||
|
}
|
||||||
|
if (!data.objectives.empty() && (q.objectives.empty() || data.objectives.size() > q.objectives.size())) {
|
||||||
|
q.objectives = data.objectives;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
questDetailsOpen = true;
|
questDetailsOpen = true;
|
||||||
gossipWindowOpen = false;
|
gossipWindowOpen = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
#include "core/application.hpp"
|
#include "core/application.hpp"
|
||||||
#include "core/input.hpp"
|
#include "core/input.hpp"
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace wowee { namespace ui {
|
namespace wowee { namespace ui {
|
||||||
|
|
||||||
|
|
@ -22,37 +23,23 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
|
||||||
std::string result = text;
|
std::string result = text;
|
||||||
|
|
||||||
auto trim = [](std::string& s) {
|
auto trim = [](std::string& s) {
|
||||||
s.erase(0, s.find_first_not_of(" \t\n\r"));
|
const char* ws = " \t\n\r";
|
||||||
s.erase(s.find_last_not_of(" \t\n\r") + 1);
|
size_t start = s.find_first_not_of(ws);
|
||||||
|
if (start == std::string::npos) { s.clear(); return; }
|
||||||
|
size_t end = s.find_last_not_of(ws);
|
||||||
|
s = s.substr(start, end - start + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace simple placeholders
|
// Replace $g placeholders
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
pos = 0;
|
||||||
while ((pos = result.find('$', pos)) != std::string::npos) {
|
while ((pos = result.find('$', pos)) != std::string::npos) {
|
||||||
if (pos + 1 >= result.length()) break;
|
if (pos + 1 >= result.length()) break;
|
||||||
|
char marker = result[pos + 1];
|
||||||
|
if (marker != 'g' && marker != 'G') { pos++; continue; }
|
||||||
|
|
||||||
char code = result[pos + 1];
|
|
||||||
std::string replacement;
|
|
||||||
|
|
||||||
switch (code) {
|
|
||||||
case 'n': case 'N': replacement = playerName; break;
|
|
||||||
case 'p': replacement = pronouns.subject; break;
|
|
||||||
case 'o': replacement = pronouns.object; break;
|
|
||||||
case 's': replacement = pronouns.possessive; break;
|
|
||||||
case 'S': replacement = pronouns.possessiveP; break;
|
|
||||||
case 'g': pos++; continue;
|
|
||||||
default: pos++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.replace(pos, 2, replacement);
|
|
||||||
pos += replacement.length();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace $g placeholders
|
|
||||||
pos = 0;
|
|
||||||
while ((pos = result.find("$g", pos)) != std::string::npos) {
|
|
||||||
size_t endPos = result.find(';', pos);
|
size_t endPos = result.find(';', pos);
|
||||||
if (endPos == std::string::npos) break;
|
if (endPos == std::string::npos) { pos += 2; continue; }
|
||||||
|
|
||||||
std::string placeholder = result.substr(pos + 2, endPos - pos - 2);
|
std::string placeholder = result.substr(pos + 2, endPos - pos - 2);
|
||||||
|
|
||||||
|
|
@ -93,14 +80,130 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
|
||||||
pos += replacement.length();
|
pos += replacement.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace simple placeholders
|
||||||
|
pos = 0;
|
||||||
|
while ((pos = result.find('$', pos)) != std::string::npos) {
|
||||||
|
if (pos + 1 >= result.length()) break;
|
||||||
|
|
||||||
|
char code = result[pos + 1];
|
||||||
|
std::string replacement;
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'n': case 'N': replacement = playerName; break;
|
||||||
|
case 'p': replacement = pronouns.subject; break;
|
||||||
|
case 'o': replacement = pronouns.object; break;
|
||||||
|
case 's': replacement = pronouns.possessive; break;
|
||||||
|
case 'S': replacement = pronouns.possessiveP; break;
|
||||||
|
case 'r': replacement = pronouns.object; break;
|
||||||
|
case 'b': replacement = "\n"; break;
|
||||||
|
case 'g': case 'G': pos++; continue;
|
||||||
|
default: pos++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.replace(pos, 2, replacement);
|
||||||
|
pos += replacement.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WoW markup linebreak token
|
||||||
|
pos = 0;
|
||||||
|
while ((pos = result.find("|n", pos)) != std::string::npos) {
|
||||||
|
result.replace(pos, 2, "\n");
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) {
|
||||||
|
std::string s = raw;
|
||||||
|
|
||||||
|
auto looksUtf16LeBytes = [](const std::string& str) -> bool {
|
||||||
|
if (str.size() < 6) return false;
|
||||||
|
size_t nulCount = 0;
|
||||||
|
size_t oddNul = 0;
|
||||||
|
for (size_t i = 0; i < str.size(); i++) {
|
||||||
|
if (str[i] == '\0') {
|
||||||
|
nulCount++;
|
||||||
|
if (i & 1) oddNul++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (nulCount >= str.size() / 4) && (oddNul >= (nulCount * 3) / 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (looksUtf16LeBytes(s)) {
|
||||||
|
std::string collapsed;
|
||||||
|
collapsed.reserve(s.size() / 2);
|
||||||
|
for (size_t i = 0; i + 1 < s.size(); i += 2) {
|
||||||
|
unsigned char lo = static_cast<unsigned char>(s[i]);
|
||||||
|
unsigned char hi = static_cast<unsigned char>(s[i + 1]);
|
||||||
|
if (lo == 0 && hi == 0) break;
|
||||||
|
if (hi != 0) { collapsed.clear(); break; }
|
||||||
|
collapsed.push_back(static_cast<char>(lo));
|
||||||
|
}
|
||||||
|
if (!collapsed.empty()) s = std::move(collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a stable ASCII view for list rendering; malformed multibyte/UTF-16 noise
|
||||||
|
// is a common source of one-glyph/half-glyph quest labels.
|
||||||
|
std::string ascii;
|
||||||
|
ascii.reserve(s.size());
|
||||||
|
for (unsigned char uc : s) {
|
||||||
|
if (uc >= 0x20 && uc <= 0x7E) ascii.push_back(static_cast<char>(uc));
|
||||||
|
else if (uc == '\t' || uc == '\n' || uc == '\r') ascii.push_back(' ');
|
||||||
|
}
|
||||||
|
if (ascii.size() >= 4) s = std::move(ascii);
|
||||||
|
|
||||||
|
for (char& c : s) {
|
||||||
|
unsigned char uc = static_cast<unsigned char>(c);
|
||||||
|
if (uc == 0) { c = ' '; continue; }
|
||||||
|
if (uc < 0x20 && c != '\n' && c != '\t') c = ' ';
|
||||||
|
}
|
||||||
|
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
|
||||||
|
while (!s.empty() && s.back() == ' ') s.pop_back();
|
||||||
|
|
||||||
|
int alphaCount = 0;
|
||||||
|
int spaceCount = 0;
|
||||||
|
int shortWordCount = 0;
|
||||||
|
int wordCount = 0;
|
||||||
|
int currentWordLen = 0;
|
||||||
|
for (char c : s) {
|
||||||
|
if (std::isalpha(static_cast<unsigned char>(c))) alphaCount++;
|
||||||
|
if (c == ' ') {
|
||||||
|
spaceCount++;
|
||||||
|
if (currentWordLen > 0) {
|
||||||
|
wordCount++;
|
||||||
|
if (currentWordLen <= 1) shortWordCount++;
|
||||||
|
currentWordLen = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentWordLen++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentWordLen > 0) {
|
||||||
|
wordCount++;
|
||||||
|
if (currentWordLen <= 1) shortWordCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic for broken UTF-16-like text that turns into "T h e B e g i n n i n g".
|
||||||
|
if (wordCount >= 6 && shortWordCount == wordCount && static_cast<int>(s.size()) > 12) {
|
||||||
|
std::string compact;
|
||||||
|
compact.reserve(s.size());
|
||||||
|
for (char c : s) {
|
||||||
|
if (c != ' ') compact.push_back(c);
|
||||||
|
}
|
||||||
|
if (compact.size() >= 4) s = compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.size() < 4) s = "Quest #" + std::to_string(questId);
|
||||||
|
if (s.size() > 72) s = s.substr(0, 72) + "...";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
||||||
// L key toggle (edge-triggered)
|
// L key toggle (edge-triggered)
|
||||||
bool wantsTextInput = ImGui::GetIO().WantTextInput;
|
ImGuiIO& io = ImGui::GetIO();
|
||||||
bool lDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L);
|
bool lDown = !io.WantTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L);
|
||||||
if (lDown && !lKeyWasDown) {
|
if (lDown && !lKeyWasDown) {
|
||||||
open = !open;
|
open = !open;
|
||||||
}
|
}
|
||||||
|
|
@ -112,52 +215,125 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
||||||
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
||||||
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
|
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
|
||||||
|
|
||||||
float logW = 380.0f;
|
float logW = std::min(980.0f, screenW - 80.0f);
|
||||||
float logH = std::min(450.0f, screenH - 120.0f);
|
float logH = std::min(620.0f, screenH - 100.0f);
|
||||||
float logX = (screenW - logW) * 0.5f;
|
float logX = (screenW - logW) * 0.5f;
|
||||||
float logY = 80.0f;
|
float logY = 50.0f;
|
||||||
|
|
||||||
ImGui::SetNextWindowPos(ImVec2(logX, logY), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowPos(ImVec2(logX, logY), ImGuiCond_FirstUseEver);
|
||||||
ImGui::SetNextWindowSize(ImVec2(logW, logH), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowSize(ImVec2(logW, logH), ImGuiCond_FirstUseEver);
|
||||||
|
|
||||||
bool stillOpen = true;
|
bool stillOpen = true;
|
||||||
if (ImGui::Begin("Quest Log", &stillOpen)) {
|
if (ImGui::Begin("Quest Log", &stillOpen)) {
|
||||||
|
const float footerHeight = 42.0f;
|
||||||
|
ImGui::BeginChild("QuestLogMain", ImVec2(0, -footerHeight), false);
|
||||||
|
|
||||||
const auto& quests = gameHandler.getQuestLog();
|
const auto& quests = gameHandler.getQuestLog();
|
||||||
|
if (selectedIndex >= static_cast<int>(quests.size())) {
|
||||||
|
selectedIndex = quests.empty() ? -1 : static_cast<int>(quests.size()) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int activeCount = 0;
|
||||||
|
int completeCount = 0;
|
||||||
|
for (const auto& q : quests) {
|
||||||
|
if (q.complete) completeCount++;
|
||||||
|
else activeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount);
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount);
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
if (quests.empty()) {
|
if (quests.empty()) {
|
||||||
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active quests.");
|
ImGui::Spacing();
|
||||||
|
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.75f, 1.0f), "No active quests.");
|
||||||
} else {
|
} else {
|
||||||
// Left panel: quest list
|
float paneW = ImGui::GetContentRegionAvail().x * 0.42f;
|
||||||
ImGui::BeginChild("QuestList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4), true);
|
if (paneW < 260.0f) paneW = 260.0f;
|
||||||
|
if (paneW > 420.0f) paneW = 420.0f;
|
||||||
|
|
||||||
|
ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true);
|
||||||
|
ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List");
|
||||||
|
ImGui::Separator();
|
||||||
for (size_t i = 0; i < quests.size(); i++) {
|
for (size_t i = 0; i < quests.size(); i++) {
|
||||||
const auto& q = quests[i];
|
const auto& q = quests[i];
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
|
|
||||||
ImVec4 color = q.complete
|
|
||||||
? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) // Green for complete
|
|
||||||
: ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // Gold for active
|
|
||||||
|
|
||||||
bool selected = (selectedIndex == static_cast<int>(i));
|
bool selected = (selectedIndex == static_cast<int>(i));
|
||||||
if (ImGui::Selectable("##quest", selected, 0, ImVec2(0, 20))) {
|
std::string displayTitle = cleanQuestTitleForUi(q.title, q.questId);
|
||||||
selectedIndex = static_cast<int>(i);
|
std::string rowText = displayTitle + (q.complete ? " [Ready]" : "");
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::TextColored(color, "%s%s",
|
|
||||||
q.title.c_str(),
|
|
||||||
q.complete ? " (Complete)" : "");
|
|
||||||
|
|
||||||
|
float rowH = 24.0f;
|
||||||
|
float rowW = ImGui::GetContentRegionAvail().x;
|
||||||
|
if (rowW < 1.0f) rowW = 1.0f;
|
||||||
|
bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH));
|
||||||
|
bool hovered = ImGui::IsItemHovered();
|
||||||
|
|
||||||
|
ImVec2 rowMin = ImGui::GetItemRectMin();
|
||||||
|
ImVec2 rowMax = ImGui::GetItemRectMax();
|
||||||
|
ImDrawList* draw = ImGui::GetWindowDrawList();
|
||||||
|
if (selected || hovered) {
|
||||||
|
ImU32 bg = selected ? IM_COL32(75, 95, 120, 190) : IM_COL32(60, 60, 60, 120);
|
||||||
|
draw->AddRectFilled(rowMin, rowMax, bg, 3.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImU32 txt = q.complete ? IM_COL32(120, 255, 120, 255) : IM_COL32(230, 230, 230, 255);
|
||||||
|
draw->AddText(ImVec2(rowMin.x + 8.0f, rowMin.y + 4.0f), txt, rowText.c_str());
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
selectedIndex = static_cast<int>(i);
|
||||||
|
if (q.objectives.empty()) {
|
||||||
|
if (gameHandler.requestQuestQuery(q.questId)) {
|
||||||
|
lastDetailRequestQuestId_ = q.questId;
|
||||||
|
}
|
||||||
|
} else if (lastDetailRequestQuestId_ == q.questId) {
|
||||||
|
lastDetailRequestQuestId_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginChild("QuestDetailsPane", ImVec2(0, 0), true);
|
||||||
|
|
||||||
// Details panel for selected quest
|
// Details panel for selected quest
|
||||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(quests.size())) {
|
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(quests.size())) {
|
||||||
const auto& sel = quests[static_cast<size_t>(selectedIndex)];
|
const auto& sel = quests[static_cast<size_t>(selectedIndex)];
|
||||||
|
std::string selectedTitle = cleanQuestTitleForUi(sel.title, sel.questId);
|
||||||
|
ImGui::TextWrapped("%s", selectedTitle.c_str());
|
||||||
|
ImGui::TextColored(sel.complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) : ImVec4(1.0f, 0.84f, 0.2f, 1.0f),
|
||||||
|
"%s", sel.complete ? "Ready to turn in" : "In progress");
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextDisabled("(Quest #%u)", sel.questId);
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
if (!sel.objectives.empty()) {
|
if (sel.objectives.empty()) {
|
||||||
ImGui::Separator();
|
if (lastDetailRequestQuestId_ != sel.questId) {
|
||||||
|
if (gameHandler.requestQuestQuery(sel.questId)) {
|
||||||
|
lastDetailRequestQuestId_ = sel.questId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastDetailRequestQuestId_ == sel.questId) {
|
||||||
|
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.8f, 1.0f), "Loading quest details...");
|
||||||
|
} else {
|
||||||
|
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.8f, 1.0f), "Quest summary not available yet.");
|
||||||
|
}
|
||||||
|
if (ImGui::Button("Retry Details")) {
|
||||||
|
if (gameHandler.requestQuestQuery(sel.questId, true)) {
|
||||||
|
lastDetailRequestQuestId_ = sel.questId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (lastDetailRequestQuestId_ == sel.questId) lastDetailRequestQuestId_ = 0;
|
||||||
|
ImGui::TextColored(ImVec4(0.82f, 0.9f, 1.0f, 1.0f), "Summary");
|
||||||
std::string processedObjectives = replaceGenderPlaceholders(sel.objectives, gameHandler);
|
std::string processedObjectives = replaceGenderPlaceholders(sel.objectives, gameHandler);
|
||||||
|
float textHeight = ImGui::GetContentRegionAvail().y * 0.45f;
|
||||||
|
if (textHeight < 120.0f) textHeight = 120.0f;
|
||||||
|
ImGui::BeginChild("QuestObjectiveText", ImVec2(0, textHeight), true);
|
||||||
ImGui::TextWrapped("%s", processedObjectives.c_str());
|
ImGui::TextWrapped("%s", processedObjectives.c_str());
|
||||||
|
ImGui::EndChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sel.killCounts.empty() || !sel.itemCounts.empty()) {
|
if (!sel.killCounts.empty() || !sel.itemCounts.empty()) {
|
||||||
|
|
@ -178,14 +354,23 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
||||||
// Abandon button
|
// Abandon button
|
||||||
if (!sel.complete) {
|
if (!sel.complete) {
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::Button("Abandon Quest")) {
|
if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) {
|
||||||
gameHandler.abandonQuest(sel.questId);
|
gameHandler.abandonQuest(sel.questId);
|
||||||
if (selectedIndex >= static_cast<int>(quests.size())) {
|
selectedIndex = -1;
|
||||||
selectedIndex = static_cast<int>(quests.size()) - 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ImGui::TextColored(ImVec4(0.72f, 0.72f, 0.76f, 1.0f), "Select a quest to view details.");
|
||||||
}
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
float closeW = ImGui::GetContentRegionAvail().x;
|
||||||
|
if (closeW < 220.0f) closeW = 220.0f;
|
||||||
|
if (ImGui::Button("Close Quest Log", ImVec2(closeW, 34.0f))) {
|
||||||
|
stillOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue