From 394e91cd9ec9fd769abea12bdcc4b9bee41a2df1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Feb 2026 14:24:38 -0800 Subject: [PATCH] Add character screen model preview, item icons, stats panel, and fix targeting bugs Enhanced the C-key character screen with a 3-column layout featuring a 3D character model preview (with drag-to-rotate), item icons loaded from BLP textures via ItemDisplayInfo.dbc, and a stats panel showing base + equipment bonuses. Fixed selection circle clipping under terrain by adding a Z offset, and corrected faction hostility logic that was wrongly marking hostile mobs as friendly. --- include/game/entity.hpp | 46 +++ include/game/game_handler.hpp | 10 + include/game/npc_manager.hpp | 2 + include/rendering/character_preview.hpp | 5 + include/ui/game_screen.hpp | 1 + include/ui/inventory_screen.hpp | 50 ++- src/core/application.cpp | 24 ++ src/game/game_handler.cpp | 24 +- src/game/npc_manager.cpp | 56 ++- src/rendering/renderer.cpp | 5 +- src/ui/game_screen.cpp | 56 ++- src/ui/inventory_screen.cpp | 512 ++++++++++++++++++++++-- 12 files changed, 738 insertions(+), 53 deletions(-) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 51a4b4d1..a6bf1a64 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -72,8 +72,39 @@ public: y = py; z = pz; orientation = o; + isMoving_ = false; // Instant position set cancels interpolation } + // Movement interpolation (syncs entity position with renderer during movement) + void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) { + if (durationSec <= 0.0f) { + setPosition(destX, destY, destZ, destO); + return; + } + moveStartX_ = x; moveStartY_ = y; moveStartZ_ = z; + moveEndX_ = destX; moveEndY_ = destY; moveEndZ_ = destZ; + moveDuration_ = durationSec; + moveElapsed_ = 0.0f; + orientation = destO; + isMoving_ = true; + } + + void updateMovement(float deltaTime) { + if (!isMoving_) return; + moveElapsed_ += deltaTime; + float t = moveElapsed_ / moveDuration_; + if (t >= 1.0f) { + x = moveEndX_; y = moveEndY_; z = moveEndZ_; + isMoving_ = false; + } else { + x = moveStartX_ + (moveEndX_ - moveStartX_) * t; + y = moveStartY_ + (moveEndY_ - moveStartY_) * t; + z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + } + } + + bool isEntityMoving() const { return isMoving_; } + // Object type ObjectType getType() const { return type; } void setType(ObjectType t) { type = t; } @@ -108,6 +139,13 @@ protected: // Update fields (dynamic values) std::map fields; + + // Movement interpolation state + bool isMoving_ = false; + float moveStartX_ = 0, moveStartY_ = 0, moveStartZ_ = 0; + float moveEndX_ = 0, moveEndY_ = 0, moveEndZ_ = 0; + float moveDuration_ = 0; + float moveElapsed_ = 0; }; /** @@ -162,6 +200,12 @@ public: // Returns true if NPC has interaction flags (gossip/vendor/quest/trainer) bool isInteractable() const { return npcFlags != 0; } + // Faction-based hostility + uint32_t getFactionTemplate() const { return factionTemplate; } + void setFactionTemplate(uint32_t f) { factionTemplate = f; } + bool isHostile() const { return hostile; } + void setHostile(bool h) { hostile = h; } + protected: std::string name; uint32_t health = 0; @@ -174,6 +218,8 @@ protected: uint32_t displayId = 0; uint32_t unitFlags = 0; uint32_t npcFlags = 0; + uint32_t factionTemplate = 0; + bool hostile = false; }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 20159ccc..ac338315 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -294,6 +294,9 @@ public: using CreatureDespawnCallback = std::function; void setCreatureDespawnCallback(CreatureDespawnCallback cb) { creatureDespawnCallback_ = std::move(cb); } + // Faction hostility map (populated from FactionTemplate.dbc by Application) + void setFactionHostileMap(std::unordered_map map) { factionHostileMap_ = std::move(map); } + // Creature move callback (online mode - triggered by SMSG_MONSTER_MOVE) // Parameters: guid, x, y, z (canonical), duration_ms (0 = instant) using CreatureMoveCallback = std::function; @@ -644,6 +647,13 @@ private: // Quest log std::vector questLog_; + // Faction hostility lookup (populated from FactionTemplate.dbc) + std::unordered_map factionHostileMap_; + bool isHostileFaction(uint32_t factionTemplateId) const { + auto it = factionHostileMap_.find(factionTemplateId); + return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown + } + // Vendor bool vendorWindowOpen = false; ListInventoryData currentVendorItems; diff --git a/include/game/npc_manager.hpp b/include/game/npc_manager.hpp index cdfc4ae8..96d1987a 100644 --- a/include/game/npc_manager.hpp +++ b/include/game/npc_manager.hpp @@ -26,6 +26,8 @@ struct NpcSpawnDef { float rotation; // radians around Z float scale; bool isCritter; // critters don't do humanoid emotes + uint32_t faction = 0; // faction template ID from creature_template + uint32_t npcFlags = 0; // NPC interaction flags from creature_template }; struct NpcInstance { diff --git a/include/rendering/character_preview.hpp b/include/rendering/character_preview.hpp index 2ad1814b..4c0b5a68 100644 --- a/include/rendering/character_preview.hpp +++ b/include/rendering/character_preview.hpp @@ -33,6 +33,11 @@ public: int getWidth() const { return fboWidth_; } int getHeight() const { return fboHeight_; } + CharacterRenderer* getCharacterRenderer() { return charRenderer_.get(); } + uint32_t getInstanceId() const { return instanceId_; } + uint32_t getModelId() const { return PREVIEW_MODEL_ID; } + bool isModelLoaded() const { return modelLoaded_; } + private: void createFBO(); void destroyFBO(); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 93f9791f..84157d54 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -3,6 +3,7 @@ #include "game/game_handler.hpp" #include "game/inventory.hpp" #include "rendering/world_map.hpp" +#include "rendering/character_preview.hpp" #include "ui/inventory_screen.hpp" #include "ui/quest_log_screen.hpp" #include "ui/spellbook_screen.hpp" diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 50cebb4d..54a7517f 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -1,21 +1,29 @@ #pragma once #include "game/inventory.hpp" +#include "game/character.hpp" #include "game/world_packets.hpp" +#include #include #include +#include +#include namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { class CharacterPreview; class CharacterRenderer; } namespace game { class GameHandler; } namespace ui { class InventoryScreen { public: + ~InventoryScreen(); + /// Render bags window (B key). Positioned at bottom of screen. void render(game::Inventory& inventory, uint64_t moneyCopper); /// Render character screen (C key). Standalone equipment window. - void renderCharacterScreen(game::Inventory& inventory); + void renderCharacterScreen(game::GameHandler& gameHandler); bool isOpen() const { return open; } void toggle() { open = !open; } @@ -31,6 +39,21 @@ public: gameHandler_ = handler; } + /// Set asset manager for icon/model loading + void setAssetManager(pipeline::AssetManager* am) { assetManager_ = am; } + + /// Store player appearance for character preview + void setPlayerAppearance(game::Race race, game::Gender gender, + uint8_t skin, uint8_t face, + uint8_t hairStyle, uint8_t hairColor, + uint8_t facialHair); + + /// Mark the character preview as needing equipment update + void markPreviewDirty() { previewDirty_ = true; } + + /// Update the preview animation (call each frame) + void updatePreview(float deltaTime); + /// Returns true if equipment changed since last call, and clears the flag. bool consumeEquipmentDirty() { bool d = equipmentDirty; equipmentDirty = false; return d; } /// Returns true if any inventory slot changed since last call, and clears the flag. @@ -48,6 +71,30 @@ private: bool vendorMode_ = false; game::GameHandler* gameHandler_ = nullptr; + // Asset manager for icons and preview + pipeline::AssetManager* assetManager_ = nullptr; + + // Item icon cache: displayInfoId -> GL texture + std::unordered_map iconCache_; + GLuint getItemIcon(uint32_t displayInfoId); + + // Character model preview + std::unique_ptr charPreview_; + bool previewInitialized_ = false; + bool previewDirty_ = false; + + // Stored player appearance for preview + game::Race playerRace_ = game::Race::HUMAN; + game::Gender playerGender_ = game::Gender::MALE; + uint8_t playerSkin_ = 0; + uint8_t playerFace_ = 0; + uint8_t playerHairStyle_ = 0; + uint8_t playerHairColor_ = 0; + uint8_t playerFacialHair_ = 0; + + void initPreview(); + void updatePreviewEquipment(game::Inventory& inventory); + // Drag-and-drop held item state bool holdingItem = false; game::ItemDef heldItem; @@ -58,6 +105,7 @@ private: void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory); + void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel); // Slot rendering with interaction support enum class SlotKind { BACKPACK, EQUIPMENT }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 796b6bf2..b8b02820 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -626,6 +626,30 @@ void Application::setupUICallbacks() { loadOnlineWorldTerrain(mapId, x, y, z); }); + // Load faction hostility map from FactionTemplate.dbc (used for both single-player and online) + if (assetManager && assetManager->isInitialized()) { + if (auto dbc = assetManager->loadDBC("FactionTemplate.dbc"); dbc && dbc->isLoaded()) { + uint32_t playerFriendGroup = 0; + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + if (dbc->getUInt32(i, 0) == 1) { // Human player faction template + playerFriendGroup = dbc->getUInt32(i, 4) | dbc->getUInt32(i, 3); + break; + } + } + std::unordered_map factionMap; + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); + uint32_t enemyGroup = dbc->getUInt32(i, 5); + uint32_t friendGroup = dbc->getUInt32(i, 4); + bool hostile = (enemyGroup & playerFriendGroup) != 0; + bool friendly = (friendGroup & playerFriendGroup) != 0; + factionMap[id] = hostile ? true : (!friendly && enemyGroup == 0 && friendGroup == 0); + } + gameHandler->setFactionHostileMap(std::move(factionMap)); + LOG_INFO("Loaded faction hostility data (playerFriendGroup=0x", std::hex, playerFriendGroup, std::dec, ")"); + } + } + // Creature spawn callback (online mode) - spawn creature models gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { // Queue spawns to avoid hanging when many creatures appear at once diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 007116c3..218e84b4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -835,6 +835,11 @@ void GameHandler::update(float deltaTime) { // Update combat text (Phase 2) updateCombatText(deltaTime); + // Update entity movement interpolation (keeps targeting in sync with visuals) + for (auto& [guid, entity] : entityManager.getEntities()) { + entity->updateMovement(deltaTime); + } + // Single-player local combat if (singlePlayerMode_) { updateLocalCombat(deltaTime); @@ -2480,6 +2485,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (it != block.fields.end() && it->second != 0) { auto unit = std::static_pointer_cast(entity); unit->setEntry(it->second); + // Set name from cache immediately if available + std::string cached = getCachedCreatureName(it->second); + if (!cached.empty()) { + unit->setName(cached); + } queryCreatureInfo(it->second, block.guid); } } @@ -2493,6 +2503,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { case 25: unit->setPower(val); break; case 32: unit->setMaxHealth(val); break; case 33: unit->setMaxPower(val); break; + case 55: unit->setFactionTemplate(val); break; // UNIT_FIELD_FACTIONTEMPLATE case 59: unit->setUnitFlags(val); break; // UNIT_FIELD_FLAGS case 54: unit->setLevel(val); break; case 67: unit->setDisplayId(val); break; // UNIT_FIELD_DISPLAYID @@ -2500,6 +2511,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { default: break; } } + // Determine hostility from faction template for online creatures + if (unit->getFactionTemplate() != 0) { + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); + } // Trigger creature spawn callback for units with displayId if (block.objectType == ObjectType::UNIT && unit->getDisplayId() != 0) { if (creatureSpawnCallback_) { @@ -2591,6 +2606,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { case 33: unit->setMaxPower(val); break; case 59: unit->setUnitFlags(val); break; // UNIT_FIELD_FLAGS case 54: unit->setLevel(val); break; + case 55: // UNIT_FIELD_FACTIONTEMPLATE + unit->setFactionTemplate(val); + unit->setHostile(isHostileFaction(val)); + break; case 82: unit->setNpcFlags(val); break; // UNIT_NPC_FLAGS default: break; } @@ -3191,8 +3210,9 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { } } - // Set entity to destination for targeting/logic; renderer interpolates visually - entity->setPosition(destCanonical.x, destCanonical.y, destCanonical.z, orientation); + // Interpolate entity position alongside renderer (so targeting matches visual) + entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, + orientation, data.duration / 1000.0f); // Notify renderer to smoothly move the creature if (creatureMoveCallback_) { diff --git a/src/game/npc_manager.cpp b/src/game/npc_manager.cpp index cdf40d8c..80eeebde 100644 --- a/src/game/npc_manager.cpp +++ b/src/game/npc_manager.cpp @@ -497,6 +497,8 @@ std::vector NpcManager::loadSpawnDefsFromAzerothCoreDb( uint32_t level = 1; uint32_t health = 100; std::string m2Path; + uint32_t faction = 0; + uint32_t npcFlags = 0; }; std::unordered_map templates; @@ -546,20 +548,24 @@ std::vector NpcManager::loadSpawnDefsFromAzerothCoreDb( } }; - // Parse creature_template.sql: entry, modelid1(displayId), name, minlevel. + // Parse creature_template.sql: entry, modelid1(displayId), name, minlevel, faction, npcflag. { std::ifstream in(tmplPath); processInsertStatements(in, [&](const std::vector& cols) { - if (cols.size() < 16) return true; + if (cols.size() < 19) return true; try { uint32_t entry = static_cast(std::stoul(cols[0])); uint32_t displayId = static_cast(std::stoul(cols[6])); std::string name = unquoteSqlString(cols[10]); uint32_t minLevel = static_cast(std::stoul(cols[14])); + uint32_t faction = static_cast(std::stoul(cols[17])); + uint32_t npcflag = static_cast(std::stoul(cols[18])); TemplateRow tr; tr.name = name.empty() ? ("Creature " + std::to_string(entry)) : name; tr.level = std::max(1u, minLevel); tr.health = 150 + tr.level * 35; + tr.faction = faction; + tr.npcFlags = npcflag; auto itModel = displayToModel.find(displayId); if (itModel != displayToModel.end()) { auto itPath = modelToPath.find(itModel->second); @@ -604,6 +610,8 @@ std::vector NpcManager::loadSpawnDefsFromAzerothCoreDb( def.level = it->second.level; def.health = std::max(it->second.health, curhealth); def.m2Path = it->second.m2Path; + def.faction = it->second.faction; + def.npcFlags = it->second.npcFlags; } else { def.entry = entry; def.name = "Creature " + std::to_string(entry); @@ -709,6 +717,44 @@ void NpcManager::initialize(pipeline::AssetManager* am, } } + // Build faction hostility lookup from FactionTemplate.dbc. + // Player is Alliance (Human) — faction template 1, friendGroup includes Alliance mask. + // A creature is hostile if its enemyGroup overlaps the player's friendGroup. + std::unordered_map factionHostile; // factionTemplateId → hostile to player + { + // FactionTemplate.dbc columns (3.3.5a): + // 0: ID, 1: Faction, 2: Flags, 3: FactionGroup, 4: FriendGroup, 5: EnemyGroup, + // 6-9: Enemies[4], 10-13: Friends[4] + uint32_t playerFriendGroup = 0; + if (auto dbc = am->loadDBC("FactionTemplate.dbc"); dbc && dbc->isLoaded()) { + // First pass: find player faction template (ID 1) friendGroup + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + if (dbc->getUInt32(i, 0) == 1) { + playerFriendGroup = dbc->getUInt32(i, 4); // FriendGroup + // Also include our own factionGroup as friendly + playerFriendGroup |= dbc->getUInt32(i, 3); + break; + } + } + // Second pass: classify each faction template + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); + uint32_t enemyGroup = dbc->getUInt32(i, 5); + uint32_t friendGroup = dbc->getUInt32(i, 4); + // Hostile if creature's enemy groups overlap player's faction/friend groups + bool hostile = (enemyGroup & playerFriendGroup) != 0; + // Friendly only if creature's friendGroup explicitly includes player's groups + bool friendly = (friendGroup & playerFriendGroup) != 0; + // Hostile if explicitly hostile, or if no explicit relationship at all + factionHostile[id] = hostile ? true : (!friendly && enemyGroup == 0 && friendGroup == 0); + } + LOG_INFO("NpcManager: loaded ", dbc->getRecordCount(), + " faction templates (playerFriendGroup=0x", std::hex, playerFriendGroup, std::dec, ")"); + } else { + LOG_WARNING("NpcManager: FactionTemplate.dbc not available, all NPCs default to hostile"); + } + } + // Spawn each NPC instance for (const auto* sPtr : active) { const auto& s = *sPtr; @@ -751,6 +797,12 @@ void NpcManager::initialize(pipeline::AssetManager* am, if (s.entry != 0) { unit->setEntry(s.entry); } + unit->setNpcFlags(s.npcFlags); + unit->setFactionTemplate(s.faction); + + // Determine hostility from faction template + auto fIt = factionHostile.find(s.faction); + unit->setHostile(fIt != factionHostile.end() ? fIt->second : true); // Store canonical WoW coordinates for targeting/server compatibility glm::vec3 spawnCanonical = core::coords::renderToCanonical(glPos); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 96b37596..1cc094d8 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -1118,7 +1118,10 @@ void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& pro if (!selCircleVisible) return; initSelectionCircle(); - glm::mat4 model = glm::translate(glm::mat4(1.0f), selCirclePos); + // Small Z offset to prevent clipping under terrain + glm::vec3 raisedPos = selCirclePos; + raisedPos.z += 0.15f; + glm::mat4 model = glm::translate(glm::mat4(1.0f), raisedPos); model = glm::scale(model, glm::vec3(selCircleRadius)); glm::mat4 mvp = projection * view * model; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fd0ed181..2c597cab 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1,4 +1,5 @@ #include "ui/game_screen.hpp" +#include "rendering/character_preview.hpp" #include "core/application.hpp" #include "core/coordinates.hpp" #include "core/spawn_presets.hpp" @@ -101,6 +102,28 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); + // Set up inventory screen asset manager + player appearance (once) + { + static bool inventoryScreenInit = false; + if (!inventoryScreenInit) { + auto* am = core::Application::getInstance().getAssetManager(); + if (am) { + inventoryScreen.setAssetManager(am); + const auto* ch = gameHandler.getActiveCharacter(); + if (ch) { + uint8_t skin = ch->appearanceBytes & 0xFF; + uint8_t face = (ch->appearanceBytes >> 8) & 0xFF; + uint8_t hairStyle = (ch->appearanceBytes >> 16) & 0xFF; + uint8_t hairColor = (ch->appearanceBytes >> 24) & 0xFF; + inventoryScreen.setPlayerAppearance( + ch->race, ch->gender, skin, face, + hairStyle, hairColor, ch->facialFeatures); + inventoryScreenInit = true; + } + } + } + } + // Set vendor mode before rendering inventory inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler); @@ -113,7 +136,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); // Character screen (C key toggle handled inside render()) - inventoryScreen.renderCharacterScreen(gameHandler.getInventory()); + inventoryScreen.renderCharacterScreen(gameHandler); if (inventoryScreen.consumeInventoryDirty()) { gameHandler.notifyInventoryChanged(); @@ -124,6 +147,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { updateCharacterTextures(gameHandler.getInventory()); core::Application::getInstance().loadEquippedWeapons(); gameHandler.notifyEquipmentChanged(); + inventoryScreen.markPreviewDirty(); } // Update renderer face-target position and selection circle @@ -143,10 +167,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { auto unit = std::static_pointer_cast(target); if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead) - } else if (unit->isInteractable()) { - circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (friendly) - } else { + } else if (unit->isHostile()) { circleColor = glm::vec3(1.0f, 0.2f, 0.2f); // red (hostile) + } else { + circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (friendly) } } else if (target->getType() == game::ObjectType::PLAYER) { circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (player) @@ -504,17 +528,21 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { gameHandler.lootTarget(target->getGuid()); } else if (gameHandler.isSinglePlayerMode()) { - // Single-player: toggle auto-attack - if (gameHandler.isAutoAttacking()) { - gameHandler.stopAutoAttack(); - } else { - gameHandler.startAutoAttack(target->getGuid()); + // Single-player: interact with friendly NPCs, attack hostiles + if (!unit->isHostile() && unit->isInteractable()) { + gameHandler.interactWithNpc(target->getGuid()); + } else if (unit->isHostile()) { + if (gameHandler.isAutoAttacking()) { + gameHandler.stopAutoAttack(); + } else { + gameHandler.startAutoAttack(target->getGuid()); + } } } else { // Online mode: interact with friendly NPCs, attack hostiles - if (unit->isInteractable()) { + if (!unit->isHostile() && unit->isInteractable()) { gameHandler.interactWithNpc(target->getGuid()); - } else { + } else if (unit->isHostile()) { if (gameHandler.isAutoAttacking()) { gameHandler.stopAutoAttack(); } else { @@ -643,10 +671,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { auto u = std::static_pointer_cast(target); if (u->getHealth() == 0 && u->getMaxHealth() > 0) { hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - } else if (u->isInteractable()) { - hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); - } else { + } else if (u->isHostile()) { hostileColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + } else { + hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 823fe0b9..97efdfe6 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1,13 +1,29 @@ #include "ui/inventory_screen.hpp" #include "game/game_handler.hpp" +#include "core/application.hpp" #include "core/input.hpp" +#include "rendering/character_preview.hpp" +#include "rendering/character_renderer.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/blp_loader.hpp" +#include "core/logger.hpp" #include #include #include +#include namespace wowee { namespace ui { +InventoryScreen::~InventoryScreen() { + // Clean up icon textures + for (auto& [id, tex] : iconCache_) { + if (tex) glDeleteTextures(1, &tex); + } + iconCache_.clear(); +} + ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { switch (quality) { case game::ItemQuality::POOR: return ImVec4(0.62f, 0.62f, 0.62f, 1.0f); // Grey @@ -20,6 +36,272 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { } } +// ============================================================ +// Item Icon Loading +// ============================================================ + +GLuint InventoryScreen::getItemIcon(uint32_t displayInfoId) { + if (displayInfoId == 0 || !assetManager_) return 0; + + auto it = iconCache_.find(displayInfoId); + if (it != iconCache_.end()) return it->second; + + // Load ItemDisplayInfo.dbc + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) { + iconCache_[displayInfoId] = 0; + return 0; + } + + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) { + iconCache_[displayInfoId] = 0; + return 0; + } + + // Field 5 = inventoryIcon_1 + std::string iconName = displayInfoDbc->getString(static_cast(recIdx), 5); + if (iconName.empty()) { + iconCache_[displayInfoId] = 0; + return 0; + } + + std::string iconPath = "Interface\\Icons\\" + iconName + ".blp"; + auto blpData = assetManager_->readFile(iconPath); + if (blpData.empty()) { + iconCache_[displayInfoId] = 0; + return 0; + } + + auto image = pipeline::BLPLoader::load(blpData); + if (!image.isValid()) { + iconCache_[displayInfoId] = 0; + return 0; + } + + GLuint texId = 0; + glGenTextures(1, &texId); + glBindTexture(GL_TEXTURE_2D, texId); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + + iconCache_[displayInfoId] = texId; + return texId; +} + +// ============================================================ +// Character Model Preview +// ============================================================ + +void InventoryScreen::setPlayerAppearance(game::Race race, game::Gender gender, + uint8_t skin, uint8_t face, + uint8_t hairStyle, uint8_t hairColor, + uint8_t facialHair) { + playerRace_ = race; + playerGender_ = gender; + playerSkin_ = skin; + playerFace_ = face; + playerHairStyle_ = hairStyle; + playerHairColor_ = hairColor; + playerFacialHair_ = facialHair; + // Force preview reload on next render + previewInitialized_ = false; +} + +void InventoryScreen::initPreview() { + if (previewInitialized_ || !assetManager_) return; + + if (!charPreview_) { + charPreview_ = std::make_unique(); + if (!charPreview_->initialize(assetManager_)) { + LOG_WARNING("InventoryScreen: failed to init CharacterPreview"); + charPreview_.reset(); + return; + } + } + + charPreview_->loadCharacter(playerRace_, playerGender_, + playerSkin_, playerFace_, + playerHairStyle_, playerHairColor_, + playerFacialHair_); + previewInitialized_ = true; + previewDirty_ = true; // apply equipment on first load +} + +void InventoryScreen::updatePreview(float deltaTime) { + if (charPreview_ && previewInitialized_) { + charPreview_->update(deltaTime); + } +} + +void InventoryScreen::updatePreviewEquipment(game::Inventory& inventory) { + if (!charPreview_ || !charPreview_->isModelLoaded() || !assetManager_) return; + + auto* charRenderer = charPreview_->getCharacterRenderer(); + uint32_t instanceId = charPreview_->getInstanceId(); + if (!charRenderer || instanceId == 0) return; + + // --- Geosets (mirroring GameScreen::updateCharacterGeosets) --- + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + + auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t { + if (!displayInfoDbc || displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + }; + + auto findEquippedDisplayId = [&](std::initializer_list types) -> uint32_t { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (!slot.empty()) { + for (uint8_t t : types) { + if (slot.item.inventoryType == t) + return slot.item.displayInfoId; + } + } + } + return 0; + }; + + auto hasEquippedType = [&](std::initializer_list types) -> bool { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (!slot.empty()) { + for (uint8_t t : types) { + if (slot.item.inventoryType == t) return true; + } + } + } + return false; + }; + + std::unordered_set geosets; + for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); + + // Hair geoset: group 1 = 100 + hairStyle + 1 + geosets.insert(static_cast(100 + playerHairStyle_ + 1)); + // Facial hair geoset: group 2 = 200 + facialHair + 1 + geosets.insert(static_cast(200 + playerFacialHair_ + 1)); + geosets.insert(701); // Ears + + // Chest/Shirt + { + uint32_t did = findEquippedDisplayId({4, 5, 20}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 501 + gg : 501)); + uint32_t gg3 = getGeosetGroup(did, 2); + if (gg3 > 0) { + geosets.insert(static_cast(1301 + gg3)); + } + } + + // Legs + { + uint32_t did = findEquippedDisplayId({7}); + uint32_t gg = getGeosetGroup(did, 0); + if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { + geosets.insert(static_cast(gg > 0 ? 1301 + gg : 1301)); + } + } + + // Feet + { + uint32_t did = findEquippedDisplayId({8}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); + } + + // Gloves + { + uint32_t did = findEquippedDisplayId({10}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 301 + gg : 301)); + } + + // Cloak + geosets.insert(hasEquippedType({16}) ? 1502 : 1501); + + // Tabard + if (hasEquippedType({19})) { + geosets.insert(1201); + } + + charRenderer->setActiveGeosets(instanceId, geosets); + + // --- Textures (mirroring GameScreen::updateCharacterTextures) --- + auto& app = core::Application::getInstance(); + const auto& bodySkinPath = app.getBodySkinPath(); + const auto& underwearPaths = app.getUnderwearPaths(); + + if (bodySkinPath.empty() || !displayInfoDbc) return; + + static const char* componentDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + + std::vector> regionLayers; + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.displayInfoId == 0) continue; + + int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + uint32_t fieldIdx = 15 + region; + std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); + if (texName.empty()) continue; + + std::string base = "Item\\TextureComponents\\" + + std::string(componentDirs[region]) + "\\" + texName; + std::string genderSuffix = (playerGender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp"; + std::string genderPath = base + genderSuffix; + std::string unisexPath = base + "_U.blp"; + std::string fullPath; + if (assetManager_->fileExists(genderPath)) { + fullPath = genderPath; + } else if (assetManager_->fileExists(unisexPath)) { + fullPath = unisexPath; + } else { + fullPath = base + ".blp"; + } + regionLayers.emplace_back(region, fullPath); + } + } + + // Find the skin texture slot index in the preview model + // The preview model uses model ID PREVIEW_MODEL_ID; find slot for type-1 (body skin) + const auto* modelData = charRenderer->getModelData(charPreview_->getModelId()); + uint32_t skinSlot = 0; + if (modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + if (modelData->textures[ti].type == 1) { + skinSlot = static_cast(ti); + break; + } + } + } + + GLuint newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); + if (newTex != 0) { + charRenderer->setModelTexture(charPreview_->getModelId(), skinSlot, newTex); + } + + previewDirty_ = false; +} + +// ============================================================ +// Equip slot helpers +// ============================================================ + game::EquipSlot InventoryScreen::getEquipSlotForType(uint8_t inventoryType, game::Inventory& inv) { switch (inventoryType) { case 1: return game::EquipSlot::HEAD; @@ -191,19 +473,28 @@ void InventoryScreen::renderHeldItem() { ImVec4 qColor = getQualityColor(heldItem.quality); ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor); - drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), - IM_COL32(40, 35, 30, 200)); - drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), - borderCol, 0.0f, 0, 2.0f); + // Try to show icon + GLuint iconTex = getItemIcon(heldItem.displayInfoId); + if (iconTex) { + drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + size, pos.y + size)); + drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), + borderCol, 0.0f, 0, 2.0f); + } else { + drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), + IM_COL32(40, 35, 30, 200)); + drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), + borderCol, 0.0f, 0, 2.0f); - char abbr[4] = {}; - if (!heldItem.name.empty()) { - abbr[0] = heldItem.name[0]; - if (heldItem.name.size() > 1) abbr[1] = heldItem.name[1]; + char abbr[4] = {}; + if (!heldItem.name.empty()) { + abbr[0] = heldItem.name[0]; + if (heldItem.name.size() > 1) abbr[1] = heldItem.name[1]; + } + float textW = ImGui::CalcTextSize(abbr).x; + drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), + ImGui::ColorConvertFloat4ToU32(qColor), abbr); } - float textW = ImGui::CalcTextSize(abbr).x; - drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), - ImGui::ColorConvertFloat4ToU32(qColor), abbr); if (heldItem.stackCount > 1) { char countStr[16]; @@ -301,14 +592,32 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { } // ============================================================ -// Character screen (C key) — standalone equipment window +// Character screen (C key) — equipment + model preview + stats // ============================================================ -void InventoryScreen::renderCharacterScreen(game::Inventory& inventory) { +void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (!characterOpen) return; + auto& inventory = gameHandler.getInventory(); + + // Lazy-init the preview + if (!previewInitialized_ && assetManager_) { + initPreview(); + } + + // Update preview equipment if dirty + if (previewDirty_ && charPreview_ && previewInitialized_) { + updatePreviewEquipment(inventory); + } + + // Update and render the preview FBO + if (charPreview_ && previewInitialized_) { + charPreview_->update(ImGui::GetIO().DeltaTime); + charPreview_->render(); + } + ImGui::SetNextWindowPos(ImVec2(20.0f, 80.0f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(220.0f, 520.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(350.0f, 650.0f), ImGuiCond_FirstUseEver); ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; if (!ImGui::Begin("Character", &characterOpen, flags)) { @@ -316,8 +625,26 @@ void InventoryScreen::renderCharacterScreen(game::Inventory& inventory) { return; } + // Clamp window position within screen after resize + { + ImGuiIO& io = ImGui::GetIO(); + ImVec2 pos = ImGui::GetWindowPos(); + ImVec2 sz = ImGui::GetWindowSize(); + bool clamped = false; + if (pos.x + sz.x > io.DisplaySize.x) { pos.x = std::max(0.0f, io.DisplaySize.x - sz.x); clamped = true; } + if (pos.y + sz.y > io.DisplaySize.y) { pos.y = std::max(0.0f, io.DisplaySize.y - sz.y); clamped = true; } + if (pos.x < 0.0f) { pos.x = 0.0f; clamped = true; } + if (pos.y < 0.0f) { pos.y = 0.0f; clamped = true; } + if (clamped) ImGui::SetWindowPos(pos); + } + renderEquipmentPanel(inventory); + // Stats panel + ImGui::Spacing(); + ImGui::Separator(); + renderStatsPanel(inventory, gameHandler.getPlayerLevel()); + ImGui::End(); // If both bags and character are open, allow drag-and-drop between them @@ -345,10 +672,17 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { }; constexpr float slotSize = 36.0f; - constexpr float spacing = 4.0f; + constexpr float previewW = 140.0f; + + // Calculate column positions for the 3-column layout + float contentStartX = ImGui::GetCursorPosX(); + float rightColX = contentStartX + slotSize + 8.0f + previewW + 8.0f; int rows = 8; + float previewStartY = ImGui::GetCursorScreenPos().y; + for (int r = 0; r < rows; r++) { + // Left column { const auto& slot = inventory.getEquipSlot(leftSlots[r]); const char* label = game::getEquipSlotName(leftSlots[r]); @@ -360,8 +694,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { ImGui::PopID(); } - ImGui::SameLine(slotSize + spacing + 60.0f); - + // Right column + ImGui::SameLine(rightColX); { const auto& slot = inventory.getEquipSlot(rightSlots[r]); const char* label = game::getEquipSlotName(rightSlots[r]); @@ -374,6 +708,44 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { } } + float previewEndY = ImGui::GetCursorScreenPos().y; + + // Draw the 3D character preview in the center column + if (charPreview_ && previewInitialized_ && charPreview_->getTextureId()) { + float previewX = ImGui::GetWindowPos().x + contentStartX + slotSize + 8.0f; + float previewH = previewEndY - previewStartY; + // Maintain aspect ratio + float texAspect = static_cast(charPreview_->getWidth()) / static_cast(charPreview_->getHeight()); + float displayW = previewW; + float displayH = displayW / texAspect; + if (displayH > previewH) { + displayH = previewH; + displayW = displayH * texAspect; + } + float offsetX = previewX + (previewW - displayW) * 0.5f; + float offsetY = previewStartY + (previewH - displayH) * 0.5f; + + ImVec2 pMin(offsetX, offsetY); + ImVec2 pMax(offsetX + displayW, offsetY + displayH); + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + // Background for preview area + drawList->AddRectFilled(pMin, pMax, IM_COL32(13, 13, 25, 255)); + drawList->AddImage( + (ImTextureID)(uintptr_t)charPreview_->getTextureId(), + pMin, pMax, + ImVec2(0, 1), ImVec2(1, 0)); // flip Y for GL + drawList->AddRect(pMin, pMax, IM_COL32(60, 60, 80, 200)); + + // Drag-to-rotate: detect mouse drag over the preview image + ImGui::SetCursorScreenPos(pMin); + ImGui::InvisibleButton("##charPreviewDrag", ImVec2(displayW, displayH)); + if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + float dx = ImGui::GetIO().MouseDelta.x; + charPreview_->rotate(dx * 1.0f); + } + } + // Weapon row ImGui::Spacing(); ImGui::Separator(); @@ -396,6 +768,60 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { } } +// ============================================================ +// Stats Panel +// ============================================================ + +void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel) { + // Sum equipment stats + int32_t totalArmor = 0; + int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; + + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (slot.empty()) continue; + totalArmor += slot.item.armor; + totalStr += slot.item.strength; + totalAgi += slot.item.agility; + totalSta += slot.item.stamina; + totalInt += slot.item.intellect; + totalSpi += slot.item.spirit; + } + + // Base stats: 20 + level + int32_t baseStat = 20 + static_cast(playerLevel); + + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); + ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); + ImVec4 gray(0.6f, 0.6f, 0.6f, 1.0f); + + // Armor (no base) + if (totalArmor > 0) { + ImGui::TextColored(gold, "Armor: %d", totalArmor); + } else { + ImGui::TextColored(gray, "Armor: 0"); + } + + // Helper to render a stat line + auto renderStat = [&](const char* name, int32_t equipBonus) { + int32_t total = baseStat + equipBonus; + if (equipBonus > 0) { + ImGui::TextColored(white, "%s: %d", name, total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", equipBonus); + } else { + ImGui::TextColored(gray, "%s: %d", name, total); + } + }; + + renderStat("Strength", totalStr); + renderStat("Agility", totalAgi); + renderStat("Stamina", totalSta); + renderStat("Intellect", totalInt); + renderStat("Spirit", totalSpi); +} + void InventoryScreen::renderBackpackPanel(game::Inventory& inventory) { ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Backpack"); ImGui::Separator(); @@ -511,18 +937,27 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite borderCol = IM_COL32(0, 200, 0, 220); } - drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), bgCol); - drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), - borderCol, 0.0f, 0, 2.0f); + // Try to show icon + GLuint iconTex = getItemIcon(item.displayInfoId); + if (iconTex) { + drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + size, pos.y + size)); + drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), + borderCol, 0.0f, 0, 2.0f); + } else { + drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), bgCol); + drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), + borderCol, 0.0f, 0, 2.0f); - char abbr[4] = {}; - if (!item.name.empty()) { - abbr[0] = item.name[0]; - if (item.name.size() > 1) abbr[1] = item.name[1]; + char abbr[4] = {}; + if (!item.name.empty()) { + abbr[0] = item.name[0]; + if (item.name.size() > 1) abbr[1] = item.name[1]; + } + float textW = ImGui::CalcTextSize(abbr).x; + drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), + ImGui::ColorConvertFloat4ToU32(qColor), abbr); } - float textW = ImGui::CalcTextSize(abbr).x; - drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), - ImGui::ColorConvertFloat4ToU32(qColor), abbr); if (item.stackCount > 1) { char countStr[16]; @@ -654,12 +1089,23 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item) { ImGui::Text("%d Armor", item.armor); } - // Stats - if (item.stamina != 0) ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "+%d Stamina", item.stamina); - if (item.strength != 0) ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "+%d Strength", item.strength); - if (item.agility != 0) ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "+%d Agility", item.agility); - if (item.intellect != 0) ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "+%d Intellect", item.intellect); - if (item.spirit != 0) ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "+%d Spirit", item.spirit); + // Stats with "Equip:" prefix style + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + ImVec4 red(1.0f, 0.2f, 0.2f, 1.0f); + + auto renderStat = [&](int32_t val, const char* name) { + if (val > 0) { + ImGui::TextColored(green, "+%d %s", val, name); + } else if (val < 0) { + ImGui::TextColored(red, "%d %s", val, name); + } + }; + + renderStat(item.stamina, "Stamina"); + renderStat(item.strength, "Strength"); + renderStat(item.agility, "Agility"); + renderStat(item.intellect, "Intellect"); + renderStat(item.spirit, "Spirit"); // Stack info if (item.maxStack > 1) {