Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen

Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
This commit is contained in:
Kelsi 2026-02-06 16:40:44 -08:00
parent caeb6f56f7
commit 2aa8187562
10 changed files with 280 additions and 81 deletions

View file

@ -33,21 +33,13 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
gameHandler.requestCharacterList();
} else if (characters.empty()) {
ImGui::Text("No characters available.");
} else if (characters.size() == 1 && !characterSelected) {
// Auto-select the only available character
selectedCharacterIndex = 0;
selectedCharacterGuid = characters[0].guid;
characterSelected = true;
std::stringstream ss;
ss << "Entering world with " << characters[0].name << "...";
setStatus(ss.str());
if (!gameHandler.isSinglePlayerMode()) {
gameHandler.selectCharacter(characters[0].guid);
}
if (onCharacterSelected) {
onCharacterSelected(characters[0].guid);
}
} else {
// Auto-highlight the first character if none selected yet
if (selectedCharacterIndex < 0 && !characters.empty()) {
selectedCharacterIndex = 0;
selectedCharacterGuid = characters[0].guid;
}
// Character table
if (ImGui::BeginTable("CharactersTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);

View file

@ -362,10 +362,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImVec4 color = getChatTypeColor(msg.type);
ImGui::PushStyleColor(ImGuiCol_Text, color);
if (msg.type == game::ChatType::TEXT_EMOTE) {
if (msg.type == game::ChatType::SYSTEM) {
// System messages: just yellow text, no header
ImGui::TextWrapped("%s", msg.message.c_str());
} else if (msg.type == game::ChatType::TEXT_EMOTE) {
ImGui::TextWrapped("You %s", msg.message.c_str());
} else if (!msg.senderName.empty()) {
ImGui::TextWrapped("[%s] %s: %s", getChatTypeName(msg.type), msg.senderName.c_str(), msg.message.c_str());
if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_YELL) {
ImGui::TextWrapped("%s says: %s", msg.senderName.c_str(), msg.message.c_str());
} else {
ImGui::TextWrapped("[%s] %s: %s", getChatTypeName(msg.type), msg.senderName.c_str(), msg.message.c_str());
}
} else {
ImGui::TextWrapped("[%s] %s", getChatTypeName(msg.type), msg.message.c_str());
}
@ -521,14 +528,58 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (closestGuid != 0) {
gameHandler.setTarget(closestGuid);
} else {
// Clicked empty space — deselect current target
gameHandler.clearTarget();
}
// Don't clear on miss — left-click is also used for camera orbit
}
}
// Right-click on target for NPC interaction / loot / auto-attack
// Right-click: select NPC (if needed) then interact / loot / auto-attack
// Suppress when left button is held (both-button run)
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT) && !input.isMouseButtonPressed(SDL_BUTTON_LEFT)) {
// If no target or right-clicking in world, try to pick one under cursor
{
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (camera && window) {
glm::vec2 mousePos = input.getMousePosition();
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH);
float closestT = 1e30f;
uint64_t closestGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue;
if (guid == myGuid) continue;
float hitRadius = 1.5f;
float heightOffset = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
}
glm::vec3 entityGL = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
entityGL.z += heightOffset;
float hitT;
if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) {
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
}
}
}
if (closestGuid != 0) {
gameHandler.setTarget(closestGuid);
}
}
}
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
@ -1734,17 +1785,27 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
// Items with icons and labels
constexpr float iconSize = 32.0f;
int lootSlotClicked = -1; // defer loot pickup to avoid iterator invalidation
for (const auto& item : loot.items) {
ImGui::PushID(item.slotIndex);
// Get item info for name and quality
const auto* info = gameHandler.getItemInfo(item.itemId);
std::string itemName = info && !info->name.empty()
? info->name
: "Item #" + std::to_string(item.itemId);
game::ItemQuality quality = info
? static_cast<game::ItemQuality>(info->quality)
: game::ItemQuality::COMMON;
std::string itemName;
game::ItemQuality quality = game::ItemQuality::COMMON;
if (info && !info->name.empty()) {
itemName = info->name;
quality = static_cast<game::ItemQuality>(info->quality);
} else {
// Fallback: look up name from item template DB (single-player)
auto tplName = gameHandler.getItemTemplateName(item.itemId);
if (!tplName.empty()) {
itemName = tplName;
quality = gameHandler.getItemTemplateQuality(item.itemId);
} else {
itemName = "Item #" + std::to_string(item.itemId);
}
}
ImVec4 qColor = InventoryScreen::getQualityColor(quality);
// Get item icon
@ -1757,7 +1818,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
// Invisible selectable for click handling
if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) {
gameHandler.lootItem(item.slotIndex);
lootSlotClicked = item.slotIndex;
}
bool hovered = ImGui::IsItemHovered();
@ -1802,6 +1863,11 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
ImGui::PopID();
}
// Process deferred loot pickup (after loop to avoid iterator invalidation)
if (lootSlotClicked >= 0) {
gameHandler.lootItem(static_cast<uint8_t>(lootSlotClicked));
}
if (loot.items.empty() && loot.gold == 0) {
ImGui::TextDisabled("Empty");
}

View file

@ -587,6 +587,43 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
static_cast<unsigned long long>(copper));
ImGui::End();
// Detect held item dropped outside inventory windows → drop confirmation
if (holdingItem && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) {
dropConfirmOpen_ = true;
dropItemName_ = heldItem.name;
}
// Drop item confirmation popup — positioned near cursor
if (dropConfirmOpen_) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
ImGui::OpenPopup("##DropItem");
dropConfirmOpen_ = false;
}
if (ImGui::BeginPopup("##DropItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Text("Destroy \"%s\"?", dropItemName_.c_str());
ImGui::Spacing();
if (ImGui::Button("Yes", ImVec2(80, 0))) {
holdingItem = false;
heldItem = game::ItemDef{};
heldSource = HeldSource::NONE;
inventoryDirty = true;
if (gameHandler_) {
gameHandler_->notifyInventoryChanged();
}
dropItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("No", ImVec2(80, 0))) {
cancelPickup(inventory);
dropItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Draw held item at cursor
renderHeldItem();
}
@ -617,7 +654,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
}
ImGui::SetNextWindowPos(ImVec2(20.0f, 80.0f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(350.0f, 650.0f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(380.0f, 650.0f), ImGuiCond_FirstUseEver);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse;
if (!ImGui::Begin("Character", &characterOpen, flags)) {
@ -640,8 +677,8 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
renderEquipmentPanel(inventory);
// Stats panel
ImGui::Spacing();
// Stats panel — use full width and separate from equipment layout
ImGui::SetCursorPosX(ImGui::GetStyle().WindowPadding.x);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
@ -1114,16 +1151,13 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item) {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Stack: %u/%u", item.stackCount, item.maxStack);
}
// Sell price (when vendor is open)
if (vendorMode_ && gameHandler_) {
const auto* info = gameHandler_->getItemInfo(item.itemId);
if (info && info->sellPrice > 0) {
uint32_t g = info->sellPrice / 10000;
uint32_t s = (info->sellPrice / 100) % 100;
uint32_t c = info->sellPrice % 100;
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c);
}
// Sell price
if (item.sellPrice > 0) {
uint32_t g = item.sellPrice / 10000;
uint32_t s = (item.sellPrice / 100) % 100;
uint32_t c = item.sellPrice % 100;
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c);
}
ImGui::EndTooltip();

View file

@ -36,13 +36,13 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
}
uint32_t fieldCount = dbc->getFieldCount();
if (fieldCount < 142) {
if (fieldCount < 154) {
LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+");
return;
}
// WoW 3.3.5a Spell.dbc fields:
// 0 = SpellID, 75 = Attributes, 133 = SpellIconID, 136 = SpellName, 141 = RankText
// WoW 3.3.5a Spell.dbc fields (0-based):
// 0 = SpellID, 4 = Attributes, 133 = SpellIconID, 136 = SpellName_enUS, 153 = RankText_enUS
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t spellId = dbc->getUInt32(i, 0);
@ -50,10 +50,10 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
SpellInfo info;
info.spellId = spellId;
info.attributes = dbc->getUInt32(i, 75);
info.attributes = dbc->getUInt32(i, 4);
info.iconId = dbc->getUInt32(i, 133);
info.name = dbc->getString(i, 136);
info.rank = dbc->getString(i, 141);
info.rank = dbc->getString(i, 153);
if (!info.name.empty()) {
spellData[spellId] = std::move(info);