diff --git a/CMakeLists.txt b/CMakeLists.txt index c2d2cb7d..c823ab06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -152,6 +152,7 @@ set(WOWEE_SOURCES src/ui/character_screen.cpp src/ui/game_screen.cpp src/ui/inventory_screen.cpp + src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp # Main diff --git a/include/core/application.hpp b/include/core/application.hpp index de6a01c1..c4ec59dc 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -153,8 +153,19 @@ private: std::unordered_map facialHairGeosetMap_; std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId + std::unordered_map displayIdModelCache_; // displayId → modelId (model caching) uint32_t nextCreatureModelId_ = 5000; // Model IDs for online creatures bool creatureLookupsBuilt_ = false; + + // Deferred creature spawn queue (throttles spawning to avoid hangs) + struct PendingCreatureSpawn { + uint64_t guid; + uint32_t displayId; + float x, y, z, orientation; + }; + std::vector pendingCreatureSpawns_; + static constexpr int MAX_SPAWNS_PER_FRAME = 2; + void processCreatureSpawnQueue(); }; } // namespace core diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 30412225..20159ccc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -294,6 +294,11 @@ public: using CreatureDespawnCallback = std::function; void setCreatureDespawnCallback(CreatureDespawnCallback cb) { creatureDespawnCallback_ = std::move(cb); } + // Creature move callback (online mode - triggered by SMSG_MONSTER_MOVE) + // Parameters: guid, x, y, z (canonical), duration_ms (0 = instant) + using CreatureMoveCallback = std::function; + void setCreatureMoveCallback(CreatureMoveCallback cb) { creatureMoveCallback_ = std::move(cb); } + // Cooldowns float getSpellCooldown(uint32_t spellId) const; @@ -330,17 +335,33 @@ public: bool isQuestDetailsOpen() const { return questDetailsOpen; } const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; } + // Quest log + struct QuestLogEntry { + uint32_t questId = 0; + std::string title; + std::string objectives; + bool complete = false; + }; + const std::vector& getQuestLog() const { return questLog_; } + void abandonQuest(uint32_t questId); + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count); + void sellItemBySlot(int backpackIndex); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } const ItemQueryResponseData* getItemInfo(uint32_t itemId) const { auto it = itemInfoCache_.find(itemId); return (it != itemInfoCache_.end()) ? &it->second : nullptr; } + uint64_t getBackpackItemGuid(int index) const { + if (index < 0 || index >= static_cast(backpackSlotGuids_.size())) return 0; + return backpackSlotGuids_[index]; + } + uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; } /** * Set callbacks @@ -464,6 +485,9 @@ private: // ---- XP handler ---- void handleXpGain(network::Packet& packet); + // ---- Creature movement handler ---- + void handleMonsterMove(network::Packet& packet); + // ---- Phase 5 handlers ---- void handleLootResponse(network::Packet& packet); void handleLootReleaseResponse(network::Packet& packet); @@ -580,6 +604,7 @@ private: WorldEntryCallback worldEntryCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; + CreatureMoveCallback creatureMoveCallback_; std::vector knownSpells; std::unordered_map spellCooldowns; // spellId -> remaining seconds uint8_t castCount = 0; @@ -616,6 +641,9 @@ private: bool questDetailsOpen = false; QuestDetailsData currentQuestDetails; + // Quest log + std::vector questLog_; + // Vendor bool vendorWindowOpen = false; ListInventoryData currentVendorItems; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 0f1cd447..dee0c10b 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -68,6 +68,9 @@ enum class Opcode : uint16_t { // ---- XP ---- SMSG_LOG_XPGAIN = 0x1D0, + // ---- Creature Movement ---- + SMSG_MONSTER_MOVE = 0x0DD, + // ---- Phase 2: Combat Core ---- CMSG_ATTACKSWING = 0x141, CMSG_ATTACKSTOP = 0x142, @@ -146,6 +149,7 @@ enum class Opcode : uint16_t { SMSG_QUESTGIVER_OFFER_REWARD = 0x18D, CMSG_QUESTGIVER_CHOOSE_REWARD = 0x18E, SMSG_QUESTGIVER_QUEST_COMPLETE = 0x191, + CMSG_QUESTLOG_REMOVE_QUEST = 0x194, // ---- Phase 5: Vendor ---- CMSG_LIST_INVENTORY = 0x19E, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 718b1911..4e4e95cd 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -740,6 +740,7 @@ struct ItemQueryResponseData { int32_t agility = 0; int32_t intellect = 0; int32_t spirit = 0; + uint32_t sellPrice = 0; std::string subclassName; bool valid = false; }; @@ -754,6 +755,25 @@ public: // Phase 2: Combat Core // ============================================================ +/** SMSG_MONSTER_MOVE data */ +struct MonsterMoveData { + uint64_t guid = 0; + float x = 0, y = 0, z = 0; // Current position (server coords) + uint8_t moveType = 0; // 0=Normal, 1=Stop, 2=FacingSpot, 3=FacingTarget, 4=FacingAngle + float facingAngle = 0; + uint64_t facingTarget = 0; + uint32_t splineFlags = 0; + uint32_t duration = 0; + // Destination (final point of the spline, server coords) + float destX = 0, destY = 0, destZ = 0; + bool hasDest = false; +}; + +class MonsterMoveParser { +public: + static bool parse(network::Packet& packet, MonsterMoveData& data); +}; + /** CMSG_ATTACKSWING packet builder */ class AttackSwingPacket { public: diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 562373ad..98ca64cb 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -192,6 +192,11 @@ private: float introEndPitch = -5.0f; float introStartDistance = 12.0f; float introEndDistance = 10.0f; + + // Idle camera: triggers intro pan after IDLE_TIMEOUT seconds of no input + float idleTimer_ = 0.0f; + bool idleOrbit_ = false; // true when current intro pan is an idle orbit (loops) + static constexpr float IDLE_TIMEOUT = 120.0f; // 2 minutes }; } // namespace rendering diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 30310f6f..921dc3c4 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -61,6 +61,9 @@ public: void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation); + void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds); + void startFadeIn(uint32_t instanceId, float durationSeconds); + const pipeline::M2Model* getModelData(uint32_t modelId) const; void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId); void setInstanceVisible(uint32_t instanceId, bool visible); @@ -130,6 +133,18 @@ private: // Weapon attachments (weapons parented to this instance's bones) std::vector weaponAttachments; + // Opacity (for fade-in) + float opacity = 1.0f; + float fadeInTime = 0.0f; // elapsed fade time (seconds) + float fadeInDuration = 0.0f; // total fade duration (0 = no fade) + + // Movement interpolation + bool isMoving = false; + glm::vec3 moveStart{0.0f}; + glm::vec3 moveEnd{0.0f}; + float moveDuration = 0.0f; // seconds + float moveElapsed = 0.0f; + // Override model matrix (used for weapon instances positioned by parent bone) bool hasOverrideModelMatrix = false; glm::mat4 overrideModelMatrix{1.0f}; diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index dd513a0a..81785f06 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -123,6 +123,10 @@ public: bool isMoving() const; void triggerMeleeSwing(); + // Selection circle for targeted entity + void setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color); + void clearSelectionCircle(); + // CPU timing stats (milliseconds, last frame). double getLastUpdateMs() const { return lastUpdateMs; } double getLastRenderMs() const { return lastRenderMs; } @@ -224,6 +228,18 @@ private: // Target facing const glm::vec3* targetPosition = nullptr; + // Selection circle rendering + uint32_t selCircleVAO = 0; + uint32_t selCircleVBO = 0; + uint32_t selCircleShader = 0; + int selCircleVertCount = 0; + void initSelectionCircle(); + void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection); + glm::vec3 selCirclePos{0.0f}; + glm::vec3 selCircleColor{1.0f, 0.0f, 0.0f}; + float selCircleRadius = 1.5f; + bool selCircleVisible = false; + // Footstep event tracking (animation-driven) uint32_t footstepLastAnimationId = 0; float footstepLastNormTime = 0.0f; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f496f8d1..93f9791f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -4,6 +4,7 @@ #include "game/inventory.hpp" #include "rendering/world_map.hpp" #include "ui/inventory_screen.hpp" +#include "ui/quest_log_screen.hpp" #include "ui/spellbook_screen.hpp" #include #include @@ -143,6 +144,7 @@ private: void renderWorldMap(game::GameHandler& gameHandler); InventoryScreen inventoryScreen; + QuestLogScreen questLogScreen; SpellbookScreen spellbookScreen; rendering::WorldMap worldMap; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index ad1c62a8..50cebb4d 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -1,18 +1,36 @@ #pragma once #include "game/inventory.hpp" +#include "game/world_packets.hpp" #include +#include namespace wowee { +namespace game { class GameHandler; } namespace ui { class InventoryScreen { public: + /// 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); + bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } + bool isCharacterOpen() const { return characterOpen; } + void toggleCharacter() { characterOpen = !characterOpen; } + void setCharacterOpen(bool o) { characterOpen = o; } + + /// Enable vendor mode: right-clicking bag items sells them. + void setVendorMode(bool enabled, game::GameHandler* handler) { + vendorMode_ = enabled; + gameHandler_ = handler; + } + /// 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. @@ -20,10 +38,16 @@ public: private: bool open = false; + bool characterOpen = false; bool bKeyWasDown = false; + bool cKeyWasDown = false; bool equipmentDirty = false; bool inventoryDirty = false; + // Vendor sell mode + bool vendorMode_ = false; + game::GameHandler* gameHandler_ = nullptr; + // Drag-and-drop held item state bool holdingItem = false; game::ItemDef heldItem; diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp new file mode 100644 index 00000000..4a64fc99 --- /dev/null +++ b/include/ui/quest_log_screen.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "game/game_handler.hpp" +#include + +namespace wowee { namespace ui { + +class QuestLogScreen { +public: + void render(game::GameHandler& gameHandler); + bool isOpen() const { return open; } + void toggle() { open = !open; } + void setOpen(bool o) { open = o; } + +private: + bool open = false; + bool lKeyWasDown = false; + int selectedIndex = -1; +}; + +}} // namespace wowee::ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 1fa1de42..796b6bf2 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -444,6 +444,8 @@ void Application::update(float deltaTime) { if (!npcsSpawned && singlePlayerMode) { spawnNpcs(); } + // Process deferred online creature spawns (throttled) + processCreatureSpawnQueue(); if (npcManager && renderer && renderer->getCharacterRenderer()) { npcManager->update(deltaTime, renderer->getCharacterRenderer()); } @@ -626,7 +628,8 @@ void Application::setupUICallbacks() { // 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) { - spawnOnlineCreature(guid, displayId, x, y, z, orientation); + // Queue spawns to avoid hanging when many creatures appear at once + pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation}); }); // Creature despawn callback (online mode) - remove creature models @@ -634,6 +637,32 @@ void Application::setupUICallbacks() { despawnOnlineCreature(guid); }); + // Creature move callback (online mode) - update creature positions + gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + float durationSec = static_cast(durationMs) / 1000.0f; + renderer->getCharacterRenderer()->moveInstanceTo(it->second, renderPos, durationSec); + } + }); + + // NPC death callback (online mode) - play death animation + gameHandler->setNpcDeathCallback([this](uint64_t guid) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { + renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death + } + }); + + // NPC swing callback (online mode) - play attack animation + gameHandler->setNpcSwingCallback([this](uint64_t guid) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { + renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack + } + }); + // "Create Character" button on character screen uiManager->getCharacterScreen().setOnCreateCharacter([this]() { uiManager->getCharacterCreateScreen().reset(); @@ -1415,6 +1444,7 @@ void Application::startSinglePlayer() { // snap the third-person camera into the correct orbit position. if (spawnSnapToGround && renderer && renderer->getCameraController()) { renderer->getCameraController()->reset(); + renderer->getCameraController()->startIntroPan(2.8f, 140.0f); } if (loadingScreenOk) { @@ -1426,6 +1456,7 @@ void Application::startSinglePlayer() { auto* camCtrl = renderer->getCameraController(); gameHandler->setHearthstoneCallback([camCtrl]() { camCtrl->reset(); + camCtrl->startIntroPan(2.8f, 140.0f); }); } @@ -1549,6 +1580,7 @@ void Application::teleportTo(int presetIndex) { // Floor-snapping presets use camera reset. WMO-floor presets keep explicit Z. if (spawnSnapToGround && renderer && renderer->getCameraController()) { renderer->getCameraController()->reset(); + renderer->getCameraController()->startIntroPan(2.8f, 140.0f); } if (!spawnSnapToGround && renderer) { @@ -1592,6 +1624,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (renderer->getCameraController()) { renderer->getCameraController()->setDefaultSpawn(spawnRender, 0.0f, 15.0f); renderer->getCameraController()->reset(); + renderer->getCameraController()->startIntroPan(2.8f, 140.0f); } // Set map name for WMO renderer @@ -1802,50 +1835,61 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x auto* charRenderer = renderer->getCharacterRenderer(); - // Load model if not already loaded for this displayId - uint32_t modelId = nextCreatureModelId_++; + // Check model cache - reuse if same displayId was already loaded + uint32_t modelId = 0; + bool modelCached = false; + auto cacheIt = displayIdModelCache_.find(displayId); + if (cacheIt != displayIdModelCache_.end()) { + modelId = cacheIt->second; + modelCached = true; + } else { + // Load model from disk (only once per displayId) + modelId = nextCreatureModelId_++; - auto m2Data = assetManager->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("Failed to read creature M2: ", m2Path); - return; - } + auto m2Data = assetManager->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("Failed to read creature M2: ", m2Path); + return; + } - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("Failed to parse creature M2: ", m2Path); - return; - } + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("Failed to parse creature M2: ", m2Path); + return; + } - // Load skin file - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = assetManager->readFile(skinPath); - if (!skinData.empty()) { - pipeline::M2Loader::loadSkin(skinData, model); - } + // Load skin file + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } - // Load external .anim files for sequences without flag 0x20 - std::string basePath = m2Path.substr(0, m2Path.size() - 3); - for (uint32_t si = 0; si < model.sequences.size(); si++) { - if (!(model.sequences[si].flags & 0x20)) { - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", - basePath.c_str(), model.sequences[si].id, model.sequences[si].variationIndex); - auto animData = assetManager->readFile(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + // Load external .anim files for sequences without flag 0x20 + std::string basePath = m2Path.substr(0, m2Path.size() - 3); + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", + basePath.c_str(), model.sequences[si].id, model.sequences[si].variationIndex); + auto animData = assetManager->readFile(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } } } + + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("Failed to load creature model: ", m2Path); + return; + } + + displayIdModelCache_[displayId] = modelId; } - if (!charRenderer->loadModel(model, modelId)) { - LOG_WARNING("Failed to load creature model: ", m2Path); - return; - } - - // Apply skin textures from CreatureDisplayInfo.dbc + // Apply skin textures from CreatureDisplayInfo.dbc (only for newly loaded models) auto itDisplayData = displayDataMap_.find(displayId); - if (itDisplayData != displayDataMap_.end()) { + if (!modelCached && itDisplayData != displayDataMap_.end()) { const auto& dispData = itDisplayData->second; // Get model directory for texture path construction @@ -1858,9 +1902,17 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x LOG_DEBUG("DisplayId ", displayId, " skins: '", dispData.skin1, "', '", dispData.skin2, "', '", dispData.skin3, "' extraDisplayId=", dispData.extraDisplayId); + // Get model data from CharacterRenderer for texture iteration + const auto* modelData = charRenderer->getModelData(modelId); + if (!modelData) { + LOG_WARNING("Model data not found for modelId ", modelId); + } + // Log texture types in the model - for (size_t ti = 0; ti < model.textures.size(); ti++) { - LOG_DEBUG(" Model texture ", ti, ": type=", model.textures[ti].type, " filename='", model.textures[ti].filename, "'"); + if (modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + LOG_DEBUG(" Model texture ", ti, ": type=", modelData->textures[ti].type, " filename='", modelData->textures[ti].filename, "'"); + } } // Check if this is a humanoid NPC with extra display info @@ -1885,9 +1937,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName; GLuint finalTex = charRenderer->loadTexture(bakePath); - if (finalTex != 0) { - for (size_t ti = 0; ti < model.textures.size(); ti++) { - uint32_t texType = model.textures[ti].type; + if (finalTex != 0 && modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + uint32_t texType = modelData->textures[ti].type; if (texType == 1 || texType == 2) { charRenderer->setModelTexture(modelId, static_cast(ti), finalTex); LOG_DEBUG("Applied baked NPC texture to slot ", ti, " (type ", texType, "): ", bakePath); @@ -1926,9 +1978,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (!hairTexPath.empty()) { GLuint hairTex = charRenderer->loadTexture(hairTexPath); - if (hairTex != 0) { - for (size_t ti = 0; ti < model.textures.size(); ti++) { - if (model.textures[ti].type == 6) { + if (hairTex != 0 && modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + if (modelData->textures[ti].type == 6) { charRenderer->setModelTexture(modelId, static_cast(ti), hairTex); LOG_DEBUG("Applied hair texture to slot ", ti, ": ", hairTexPath); } @@ -1942,9 +1994,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } // Apply creature skin textures (for non-humanoid creatures) - if (!hasHumanoidTexture) { - for (size_t ti = 0; ti < model.textures.size(); ti++) { - const auto& tex = model.textures[ti]; + if (!hasHumanoidTexture && modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + const auto& tex = modelData->textures[ti]; std::string skinPath; // Creature skin types: 11 = skin1, 12 = skin2, 13 = skin3 @@ -2113,9 +2165,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } // Log model's actual submesh IDs for debugging geoset mismatches - { + if (auto* md = charRenderer->getModelData(modelId)) { std::string batchIds; - for (const auto& b : model.batches) { + for (const auto& b : md->batches) { if (!batchIds.empty()) batchIds += ","; batchIds += std::to_string(b.submeshId); } @@ -2210,8 +2262,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } - // Play idle animation + // Play idle animation and fade in charRenderer->playAnimation(instanceId, 0, true); + charRenderer->startFadeIn(instanceId, 0.5f); // Track instance creatureInstances_[guid] = instanceId; @@ -2221,6 +2274,18 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } +void Application::processCreatureSpawnQueue() { + if (pendingCreatureSpawns_.empty()) return; + + int spawned = 0; + while (!pendingCreatureSpawns_.empty() && spawned < MAX_SPAWNS_PER_FRAME) { + auto& s = pendingCreatureSpawns_.front(); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation); + pendingCreatureSpawns_.erase(pendingCreatureSpawns_.begin()); + spawned++; + } +} + void Application::despawnOnlineCreature(uint64_t guid) { auto it = creatureInstances_.find(guid); if (it == creatureInstances_.end()) return; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8880aae4..007116c3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -840,6 +840,28 @@ void GameHandler::update(float deltaTime) { updateLocalCombat(deltaTime); updateNpcAggro(deltaTime); } + + // Online mode: maintain auto-attack by periodically re-sending CMSG_ATTACKSWING + if (!singlePlayerMode_ && autoAttacking && autoAttackTarget != 0 && socket) { + auto target = entityManager.getEntity(autoAttackTarget); + if (!target) { + // Target gone + stopAutoAttack(); + } else if (target->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(target); + if (unit->getHealth() == 0) { + stopAutoAttack(); + } else { + // Re-send attack swing every 2 seconds to keep server combat alive + swingTimer_ += deltaTime; + if (swingTimer_ >= 2.0f) { + auto pkt = AttackSwingPacket::build(autoAttackTarget); + socket->send(pkt); + swingTimer_ = 0.0f; + } + } + } + } } if (singlePlayerMode_) { @@ -982,6 +1004,11 @@ void GameHandler::handlePacket(network::Packet& packet) { handleXpGain(packet); break; + // ---- Creature Movement ---- + case Opcode::SMSG_MONSTER_MOVE: + handleMonsterMove(packet); + break; + // ---- Phase 2: Combat ---- case Opcode::SMSG_ATTACKSTART: handleAttackStart(packet); @@ -1082,7 +1109,15 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PERIODICAURALOG: case Opcode::SMSG_SPELLENERGIZELOG: case Opcode::SMSG_ENVIRONMENTALDAMAGELOG: - case Opcode::SMSG_LOOT_MONEY_NOTIFY: + case Opcode::SMSG_LOOT_MONEY_NOTIFY: { + // uint32 money + uint8 soleLooter + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t amount = packet.readUInt32(); + playerMoneyCopper_ += amount; + LOG_INFO("Looted ", amount, " copper (total: ", playerMoneyCopper_, ")"); + } + break; + } case Opcode::SMSG_LOOT_CLEAR_MONEY: case Opcode::SMSG_NPC_TEXT_UPDATE: case Opcode::SMSG_SELL_ITEM: @@ -1091,12 +1126,26 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: case Opcode::MSG_RAID_TARGET_UPDATE: case Opcode::SMSG_QUESTGIVER_STATUS: + LOG_DEBUG("Ignoring SMSG_QUESTGIVER_STATUS"); + break; case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS: handleQuestDetails(packet); break; + case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: { + // Mark quest as complete in local log + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t questId = packet.readUInt32(); + for (auto& q : questLog_) { + if (q.questId == questId) { + q.complete = true; + break; + } + } + } + break; + } case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS: case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: - case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: case Opcode::SMSG_GROUP_SET_LEADER: LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); break; @@ -2478,7 +2527,12 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { for (const auto& [key, val] : block.fields) { if (key == 634) { playerXp_ = val; } // PLAYER_XP else if (key == 635) { playerNextLevelXp_ = val; } // PLAYER_NEXT_LEVEL_XP - else if (key == 54) { serverPlayerLevel_ = val; } // UNIT_FIELD_LEVEL + else if (key == 54) { + serverPlayerLevel_ = val; // UNIT_FIELD_LEVEL + for (auto& ch : characters) { + if (ch.guid == playerGuid) { ch.level = val; break; } + } + } else if (key == 632) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE else if (key >= 322 && key <= 367) { // PLAYER_FIELD_INV_SLOT_HEAD: equipment slots (23 slots × 2 fields) @@ -2522,8 +2576,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { switch (key) { case 24: unit->setHealth(val); - if (val == 0 && block.guid == autoAttackTarget) { - stopAutoAttack(); + if (val == 0) { + if (block.guid == autoAttackTarget) { + stopAutoAttack(); + } + // Trigger death animation for NPC units + if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { + npcDeathCallback_(block.guid); + } } break; case 25: unit->setPower(val); break; @@ -2540,10 +2600,29 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (block.guid == playerGuid) { bool slotsChanged = false; for (const auto& [key, val] : block.fields) { - if (key == 634) { playerXp_ = val; } - else if (key == 635) { playerNextLevelXp_ = val; } - else if (key == 54) { serverPlayerLevel_ = val; } - else if (key == 632) { playerMoneyCopper_ = val; } + if (key == 634) { + playerXp_ = val; + LOG_INFO("XP updated: ", val); + } + else if (key == 635) { + playerNextLevelXp_ = val; + LOG_INFO("Next level XP updated: ", val); + } + else if (key == 54) { + serverPlayerLevel_ = val; + LOG_INFO("Level updated: ", val); + // Update Character struct for character selection screen + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.level = val; + break; + } + } + } + else if (key == 632) { + playerMoneyCopper_ = val; + LOG_INFO("Money updated via VALUES: ", val, " copper"); + } else if (key >= 322 && key <= 367) { int slotIndex = (key - 322) / 2; bool isLow = ((key - 322) % 2 == 0); @@ -2795,6 +2874,7 @@ void GameHandler::tabTarget(float playerX, float playerY, float playerZ) { for (const auto& [guid, entity] : entityManager.getEntities()) { auto t = entity->getType(); if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue; + if (guid == playerGuid) continue; // Don't tab-target self float dx = entity->getX() - playerX; float dy = entity->getY() - playerY; float dz = entity->getZ() - playerZ; @@ -3063,9 +3143,75 @@ void GameHandler::handleAttackStop(network::Packet& packet) { AttackStopData data; if (!AttackStopParser::parse(packet, data)) return; + // Don't clear autoAttacking on SMSG_ATTACKSTOP - the server sends this + // when the attack loop pauses (out of range, etc). The player's intent + // to attack persists until target dies or player explicitly cancels. + // We'll re-send CMSG_ATTACKSWING periodically in the update loop. if (data.attackerGuid == playerGuid) { - autoAttacking = false; - autoAttackTarget = 0; + LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); + } +} + +void GameHandler::handleMonsterMove(network::Packet& packet) { + MonsterMoveData data; + if (!MonsterMoveParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_MONSTER_MOVE"); + return; + } + + // Update entity position in entity manager + auto entity = entityManager.getEntity(data.guid); + if (entity) { + if (data.hasDest) { + // Convert destination from server to canonical coords + glm::vec3 destCanonical = core::coords::serverToCanonical( + glm::vec3(data.destX, data.destY, data.destZ)); + + // Calculate facing angle + float orientation = entity->getOrientation(); + if (data.moveType == 4) { + // FacingAngle - server specifies exact angle + orientation = data.facingAngle; + } else if (data.moveType == 3) { + // FacingTarget - face toward the target entity + auto target = entityManager.getEntity(data.facingTarget); + if (target) { + float dx = target->getX() - entity->getX(); + float dy = target->getY() - entity->getY(); + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + orientation = std::atan2(dy, dx); + } + } + } else { + // Normal move - face toward destination + float dx = destCanonical.x - entity->getX(); + float dy = destCanonical.y - entity->getY(); + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + orientation = std::atan2(dy, dx); + } + } + + // Set entity to destination for targeting/logic; renderer interpolates visually + entity->setPosition(destCanonical.x, destCanonical.y, destCanonical.z, orientation); + + // Notify renderer to smoothly move the creature + if (creatureMoveCallback_) { + creatureMoveCallback_(data.guid, + destCanonical.x, destCanonical.y, destCanonical.z, + data.duration); + } + } else if (data.moveType == 1) { + // Stop at current position + glm::vec3 posCanonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, + entity->getOrientation()); + + if (creatureMoveCallback_) { + creatureMoveCallback_(data.guid, + posCanonical.x, posCanonical.y, posCanonical.z, 0); + } + } } } @@ -3556,6 +3702,20 @@ void GameHandler::acceptQuest() { auto packet = QuestgiverAcceptQuestPacket::build( currentQuestDetails.npcGuid, currentQuestDetails.questId); socket->send(packet); + + // Add to quest log + bool alreadyInLog = false; + for (const auto& q : questLog_) { + if (q.questId == currentQuestDetails.questId) { alreadyInLog = true; break; } + } + if (!alreadyInLog) { + QuestLogEntry entry; + entry.questId = currentQuestDetails.questId; + entry.title = currentQuestDetails.title; + entry.objectives = currentQuestDetails.objectives; + questLog_.push_back(entry); + } + questDetailsOpen = false; currentQuestDetails = QuestDetailsData{}; } @@ -3565,6 +3725,23 @@ void GameHandler::declineQuest() { currentQuestDetails = QuestDetailsData{}; } +void GameHandler::abandonQuest(uint32_t questId) { + // Find the quest's index in our local log + for (size_t i = 0; i < questLog_.size(); i++) { + if (questLog_[i].questId == questId) { + // Tell server to remove it (slot index in server quest log) + // We send the local index; server maps it via PLAYER_QUEST_LOG fields + if (state == WorldState::IN_WORLD && socket) { + network::Packet pkt(static_cast(Opcode::CMSG_QUESTLOG_REMOVE_QUEST)); + pkt.writeUInt8(static_cast(i)); + socket->send(pkt); + } + questLog_.erase(questLog_.begin() + static_cast(i)); + return; + } + } +} + void GameHandler::closeGossip() { gossipWindowOpen = false; currentGossip = GossipMessageData{}; @@ -3593,6 +3770,28 @@ void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count socket->send(packet); } +void GameHandler::sellItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + const auto& slot = inventory.getBackpackSlot(backpackIndex); + if (slot.empty()) return; + + if (singlePlayerMode_) { + auto it = itemInfoCache_.find(slot.item.itemId); + if (it != itemInfoCache_.end() && it->second.sellPrice > 0) { + addMoneyCopper(it->second.sellPrice); + std::string msg = "You sold " + slot.item.name + "."; + addSystemChatMessage(msg); + } + inventory.clearBackpackSlot(backpackIndex); + notifyInventoryChanged(); + } else { + uint64_t itemGuid = backpackSlotGuids_[backpackIndex]; + if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) { + sellItem(currentVendorItems.vendorGuid, itemGuid, 1); + } + } +} + void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot)) return; lootWindowOpen = true; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index a1c0408d..8215121e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -575,11 +575,23 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u packet.writeBytes(reinterpret_cast(&info.jumpXYSpeed), sizeof(float)); } - static int mvLog = 10; + // Detailed hex dump for debugging + static int mvLog = 5; if (mvLog-- > 0) { - LOG_INFO("Movement pkt: opcode=0x", std::hex, static_cast(opcode), std::dec, - " size=", packet.getSize(), " flags=0x", std::hex, info.flags, std::dec, - " pos=(", info.x, ",", info.y, ",", info.z, ")"); + const auto& raw = packet.getData(); + std::string hex; + for (size_t i = 0; i < raw.size(); i++) { + char b[4]; snprintf(b, sizeof(b), "%02x ", raw[i]); + hex += b; + } + LOG_INFO("MOVEPKT opcode=0x", std::hex, static_cast(opcode), std::dec, + " guid=0x", std::hex, playerGuid, std::dec, + " payload=", raw.size(), " bytes", + " flags=0x", std::hex, info.flags, std::dec, + " flags2=0x", std::hex, info.flags2, std::dec, + " pos=(", info.x, ",", info.y, ",", info.z, ",", info.orientation, ")", + " fallTime=", info.fallTime); + LOG_INFO("MOVEPKT hex: ", hex); } return packet; @@ -1288,7 +1300,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyPrice - packet.readUInt32(); // SellPrice + data.sellPrice = packet.readUInt32(); // SellPrice data.inventoryType = packet.readUInt32(); @@ -1339,6 +1351,118 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa return true; } +// ============================================================ +// Creature Movement +// ============================================================ + +bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { + // PackedGuid + data.guid = UpdateObjectParser::readPackedGuid(packet); + if (data.guid == 0) return false; + + // uint8 unk (toggle for MOVEMENTFLAG2_UNK7) + if (packet.getReadPos() >= packet.getSize()) return false; + packet.readUInt8(); + + // Current position (server coords: float x, y, z) + if (packet.getReadPos() + 12 > packet.getSize()) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + + // uint32 splineId + if (packet.getReadPos() + 4 > packet.getSize()) return false; + packet.readUInt32(); + + // uint8 moveType + if (packet.getReadPos() >= packet.getSize()) return false; + data.moveType = packet.readUInt8(); + + if (data.moveType == 1) { + // Stop - no more required data + data.destX = data.x; + data.destY = data.y; + data.destZ = data.z; + data.hasDest = false; + return true; + } + + // Read facing data based on move type + if (data.moveType == 2) { + // FacingSpot: float x, y, z + if (packet.getReadPos() + 12 > packet.getSize()) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } else if (data.moveType == 3) { + // FacingTarget: uint64 guid + if (packet.getReadPos() + 8 > packet.getSize()) return false; + data.facingTarget = packet.readUInt64(); + } else if (data.moveType == 4) { + // FacingAngle: float angle + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.facingAngle = packet.readFloat(); + } + + // uint32 splineFlags + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.splineFlags = packet.readUInt32(); + + // Check for animation flag (0x00000100) + if (data.splineFlags & 0x00000100) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + packet.readUInt32(); // animId + packet.readUInt32(); // effectStartTime + } + + // uint32 duration + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.duration = packet.readUInt32(); + + // Check for parabolic flag (0x00000200) + if (data.splineFlags & 0x00000200) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + packet.readFloat(); // vertAccel + packet.readUInt32(); // effectStartTime + } + + // uint32 pointCount + if (packet.getReadPos() + 4 > packet.getSize()) return false; + uint32_t pointCount = packet.readUInt32(); + + if (pointCount == 0) return true; + + // Read destination point(s) + // If UncompressedPath flag (0x00040000): all points are full float x,y,z + // Otherwise: first is packed destination, rest are packed deltas + bool uncompressed = (data.splineFlags & 0x00040000) != 0; + + if (uncompressed) { + // Read last point as destination + // Skip to last point: each point is 12 bytes + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (packet.getReadPos() + 12 > packet.getSize()) return true; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } else { + // Compressed: first 3 floats are the destination (final point) + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } + + LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec, + " type=", (int)data.moveType, " dur=", data.duration, "ms", + " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); + + return true; +} + // ============================================================ // Phase 2: Combat Core // ============================================================ diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 9d798973..382d56d3 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -144,6 +144,22 @@ void WorldSocket::send(const Packet& packet) { LOG_DEBUG("Sending world packet: opcode=0x", std::hex, opcode, std::dec, " payload=", payloadLen, " bytes (", sendData.size(), " total)"); + // Debug: dump first few movement packets + { + static int moveDump = 3; + bool isMove = (opcode >= 0xB5 && opcode <= 0xBE) || opcode == 0xC9 || opcode == 0xDA || opcode == 0xEE; + if (isMove && moveDump-- > 0) { + std::string hex = "MOVE PKT dump opcode=0x"; + char buf[8]; snprintf(buf, sizeof(buf), "%03x", opcode); hex += buf; + hex += " payload=" + std::to_string(payloadLen) + " bytes: "; + for (size_t i = 6; i < sendData.size() && i < 6 + 48; i++) { + char b[4]; snprintf(b, sizeof(b), "%02x ", sendData[i]); + hex += b; + } + LOG_INFO(hex); + } + } + // Debug: dump packet bytes for AUTH_SESSION if (opcode == 0x1ED) { std::string hexDump = "AUTH_SESSION raw bytes: "; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index a70dc37e..84cff208 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -60,6 +60,7 @@ void CameraController::startIntroPan(float durationSec, float orbitDegrees) { if (!camera) return; introActive = true; introTimer = 0.0f; + idleTimer_ = 0.0f; introDuration = std::max(0.5f, durationSec); introStartYaw = facingYaw + orbitDegrees; introEndYaw = facingYaw; @@ -96,9 +97,24 @@ void CameraController::update(float deltaTime) { bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_SPACE); + // Idle camera: any input resets the timer; timeout triggers a slow orbit pan + bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; + if (anyInput) { + idleTimer_ = 0.0f; + } else if (!introActive) { + idleTimer_ += deltaTime; + if (idleTimer_ >= IDLE_TIMEOUT) { + idleTimer_ = 0.0f; + startIntroPan(6.0f, 360.0f); // Slow full orbit + idleOrbit_ = true; + } + } + if (introActive) { - if (leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump) { + if (anyInput) { introActive = false; + idleOrbit_ = false; + idleTimer_ = 0.0f; } else { introTimer += deltaTime; float t = (introDuration > 0.0f) ? std::min(introTimer / introDuration, 1.0f) : 1.0f; @@ -109,7 +125,13 @@ void CameraController::update(float deltaTime) { camera->setRotation(yaw, pitch); facingYaw = yaw; if (t >= 1.0f) { - introActive = false; + if (idleOrbit_) { + // Loop: restart the slow orbit continuously + startIntroPan(6.0f, 360.0f); + idleOrbit_ = true; + } else { + introActive = false; + } } } // Suppress player movement/input during intro. diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index d55cbba6..cd34f8d6 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -102,6 +102,7 @@ bool CharacterRenderer::initialize() { uniform mat4 uLightSpaceMatrix; uniform int uShadowEnabled; uniform float uShadowStrength; + uniform float uOpacity; out vec4 FragColor; @@ -154,8 +155,8 @@ bool CharacterRenderer::initialize() { float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); result = mix(uFogColor, result, fogFactor); - // Force alpha=1 for opaque character rendering (baked NPC textures may have alpha=0) - FragColor = vec4(result, 1.0); + // Apply opacity (for fade-in effects) + FragColor = vec4(result, uOpacity); } )"; @@ -906,6 +907,35 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, } void CharacterRenderer::update(float deltaTime) { + // Update fade-in opacity + for (auto& [id, inst] : instances) { + if (inst.fadeInDuration > 0.0f && inst.opacity < 1.0f) { + inst.fadeInTime += deltaTime; + inst.opacity = std::min(1.0f, inst.fadeInTime / inst.fadeInDuration); + if (inst.opacity >= 1.0f) { + inst.fadeInDuration = 0.0f; + } + } + } + + // Interpolate creature movement + for (auto& [id, inst] : instances) { + if (inst.isMoving) { + inst.moveElapsed += deltaTime; + float t = inst.moveElapsed / inst.moveDuration; + if (t >= 1.0f) { + inst.position = inst.moveEnd; + inst.isMoving = false; + // Return to idle when movement completes + if (inst.currentAnimationId == 4) { + playAnimation(id, 0, true); + } + } else { + inst.position = glm::mix(inst.moveStart, inst.moveEnd, t); + } + } + } + for (auto& pair : instances) { updateAnimation(pair.second, deltaTime); } @@ -1123,6 +1153,8 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons glEnable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); characterShader->use(); characterShader->setUniform("uView", view); @@ -1155,11 +1187,15 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons const auto& gpuModel = models[instance.modelId]; + // Skip fully transparent instances + if (instance.opacity <= 0.0f) continue; + // Set model matrix (use override for weapon instances) glm::mat4 modelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix : getModelMatrix(instance); characterShader->setUniform("uModel", modelMat); + characterShader->setUniform("uOpacity", instance.opacity); // Set bone matrices (upload all at once for performance) int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); @@ -1273,6 +1309,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } glBindVertexArray(0); + glDisable(GL_BLEND); glEnable(GL_CULL_FACE); // Restore culling for other renderers } @@ -1379,6 +1416,55 @@ void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3 } } +void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) { + auto it = instances.find(instanceId); + if (it == instances.end()) return; + + auto& inst = it->second; + if (durationSeconds <= 0.0f) { + // Instant move (stop) + inst.position = destination; + inst.isMoving = false; + // Return to idle animation if currently walking + if (inst.currentAnimationId == 4) { + playAnimation(instanceId, 0, true); + } + return; + } + + inst.moveStart = inst.position; + inst.moveEnd = destination; + inst.moveDuration = durationSeconds; + inst.moveElapsed = 0.0f; + inst.isMoving = true; + + // Face toward destination (yaw around Z axis since Z is up) + glm::vec3 dir = destination - inst.position; + if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) { + float angle = std::atan2(dir.y, dir.x); + inst.rotation.z = angle; + } + + // Play walk animation (ID 4) while moving + if (inst.currentAnimationId == 0) { + playAnimation(instanceId, 4, true); + } +} + +const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const { + auto it = models.find(modelId); + if (it == models.end()) return nullptr; + return &it->second.data; +} + +void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) { + auto it = instances.find(instanceId); + if (it == instances.end()) return; + it->second.opacity = 0.0f; + it->second.fadeInTime = 0.0f; + it->second.fadeInDuration = durationSeconds; +} + void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 7545e7ff..96b37596 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -391,7 +391,8 @@ uint32_t Renderer::resolveMeleeAnimId() { return 0.0f; }; - const uint32_t attackCandidates[] = {16, 17, 18, 19, 20, 21}; + // Prefer weapon attacks (1H=17, 2H=18) over unarmed (16); 19-21 are other variants + const uint32_t attackCandidates[] = {17, 18, 16, 19, 20, 21}; for (uint32_t id : attackCandidates) { if (characterRenderer->hasAnimation(characterInstanceId, id)) { meleeAnimId = id; @@ -1032,6 +1033,113 @@ void Renderer::update(float deltaTime) { lastUpdateMs = std::chrono::duration(updateEnd - updateStart).count(); } +// ============================================================ +// Selection Circle +// ============================================================ + +void Renderer::initSelectionCircle() { + if (selCircleVAO) return; + + // Minimal shader: position + uniform MVP + color + const char* vsSrc = R"( + #version 330 core + layout(location = 0) in vec3 aPos; + uniform mat4 uMVP; + void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + } + )"; + const char* fsSrc = R"( + #version 330 core + uniform vec3 uColor; + out vec4 FragColor; + void main() { + FragColor = vec4(uColor, 0.6); + } + )"; + + auto compile = [](GLenum type, const char* src) -> GLuint { + GLuint s = glCreateShader(type); + glShaderSource(s, 1, &src, nullptr); + glCompileShader(s); + return s; + }; + + GLuint vs = compile(GL_VERTEX_SHADER, vsSrc); + GLuint fs = compile(GL_FRAGMENT_SHADER, fsSrc); + selCircleShader = glCreateProgram(); + glAttachShader(selCircleShader, vs); + glAttachShader(selCircleShader, fs); + glLinkProgram(selCircleShader); + glDeleteShader(vs); + glDeleteShader(fs); + + // Build ring vertices (two concentric circles forming a strip) + constexpr int SEGMENTS = 48; + constexpr float INNER = 0.85f; + constexpr float OUTER = 1.0f; + std::vector verts; + for (int i = 0; i <= SEGMENTS; i++) { + float angle = 2.0f * 3.14159265f * static_cast(i) / static_cast(SEGMENTS); + float c = std::cos(angle), s = std::sin(angle); + // Outer vertex + verts.push_back(c * OUTER); + verts.push_back(s * OUTER); + verts.push_back(0.0f); + // Inner vertex + verts.push_back(c * INNER); + verts.push_back(s * INNER); + verts.push_back(0.0f); + } + selCircleVertCount = static_cast((SEGMENTS + 1) * 2); + + glGenVertexArrays(1, &selCircleVAO); + glGenBuffers(1, &selCircleVBO); + glBindVertexArray(selCircleVAO); + glBindBuffer(GL_ARRAY_BUFFER, selCircleVBO); + glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr); + glEnableVertexAttribArray(0); + glBindVertexArray(0); +} + +void Renderer::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) { + selCirclePos = pos; + selCircleRadius = radius; + selCircleColor = color; + selCircleVisible = true; +} + +void Renderer::clearSelectionCircle() { + selCircleVisible = false; +} + +void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) { + if (!selCircleVisible) return; + initSelectionCircle(); + + glm::mat4 model = glm::translate(glm::mat4(1.0f), selCirclePos); + model = glm::scale(model, glm::vec3(selCircleRadius)); + + glm::mat4 mvp = projection * view * model; + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDisable(GL_CULL_FACE); + glDepthMask(GL_FALSE); + + glUseProgram(selCircleShader); + glUniformMatrix4fv(glGetUniformLocation(selCircleShader, "uMVP"), 1, GL_FALSE, &mvp[0][0]); + glUniform3fv(glGetUniformLocation(selCircleShader, "uColor"), 1, &selCircleColor[0]); + + glBindVertexArray(selCircleVAO); + glDrawArrays(GL_TRIANGLE_STRIP, 0, selCircleVertCount); + glBindVertexArray(0); + + glDepthMask(GL_TRUE); + glEnable(GL_CULL_FACE); +} + void Renderer::renderWorld(game::World* world) { auto renderStart = std::chrono::steady_clock::now(); lastTerrainRenderMs = 0.0; @@ -1157,6 +1265,9 @@ void Renderer::renderWorld(game::World* world) { characterRenderer->render(*camera, view, projection); } + // Render selection circle under targeted creature + renderSelectionCircle(view, projection); + // Render WMO buildings (after characters, before UI) if (wmoRenderer && camera) { auto wmoStart = std::chrono::steady_clock::now(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f3e25a27..fd0ed181 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -95,12 +95,26 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Teleporter panel (T key toggle handled in Application event loop) renderTeleporterPanel(); + // Quest Log (L key toggle handled inside) + questLogScreen.render(gameHandler); + // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); - // Inventory (B key toggle handled inside) + // Set vendor mode before rendering inventory + inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler); + + // Auto-open bags when vendor window opens + if (gameHandler.isVendorWindowOpen() && !inventoryScreen.isOpen()) { + inventoryScreen.setOpen(true); + } + + // Bags (B key toggle handled inside) inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); + // Character screen (C key toggle handled inside render()) + inventoryScreen.renderCharacterScreen(gameHandler.getInventory()); + if (inventoryScreen.consumeInventoryDirty()) { gameHandler.notifyInventoryChanged(); } @@ -112,7 +126,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.notifyEquipmentChanged(); } - // Update renderer face-target position + // Update renderer face-target position and selection circle auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { static glm::vec3 targetGLPos; @@ -121,11 +135,30 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (target) { targetGLPos = core::coords::canonicalToRender(glm::vec3(target->getX(), target->getY(), target->getZ())); renderer->setTargetPosition(&targetGLPos); + + // Selection circle color: red=hostile, green=friendly, gray=dead + glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow + float circleRadius = 1.5f; + if (target->getType() == game::ObjectType::UNIT) { + 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 { + circleColor = glm::vec3(1.0f, 0.2f, 0.2f); // red (hostile) + } + } else if (target->getType() == game::ObjectType::PLAYER) { + circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (player) + } + renderer->setSelectionCircle(targetGLPos, circleRadius, circleColor); } else { renderer->setTargetPosition(nullptr); + renderer->clearSelectionCircle(); } } else { renderer->setTargetPosition(nullptr); + renderer->clearSelectionCircle(); } } } @@ -422,16 +455,29 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { 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; // Don't target self + + // Scale hitbox based on entity type + float hitRadius = 1.5f; + float heightOffset = 1.5f; + if (t == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + // Critters have very low max health (< 100) + 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())); - // Add half-height offset so we target the body center, not feet - entityGL.z += 3.0f; + entityGL.z += heightOffset; float hitT; - if (raySphereIntersect(ray, entityGL, 3.0f, hitT)) { + if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) { if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -505,11 +551,11 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { const auto& characters = gameHandler.getCharacters(); if (!characters.empty()) { - // Use the first (or most recently selected) character const auto& ch = characters[0]; playerName = ch.name; - playerLevel = ch.level; - // Characters don't store HP; use level-scaled estimate + // Use live server level if available, otherwise character struct + playerLevel = gameHandler.getPlayerLevel(); + if (playerLevel == 0) playerLevel = ch.level; playerMaxHp = 20 + playerLevel * 10; playerHp = playerMaxHp; } @@ -589,26 +635,30 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + // Determine hostility color for border and name + ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f); + if (target->getType() == game::ObjectType::PLAYER) { + hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (target->getType() == game::ObjectType::UNIT) { + 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 { + hostileColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + } + } + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f)); if (ImGui::Begin("##TargetFrame", nullptr, flags)) { // Entity name and type std::string name = getEntityName(target); - ImVec4 nameColor; - switch (target->getType()) { - case game::ObjectType::PLAYER: - nameColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - break; - case game::ObjectType::UNIT: - nameColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // Yellow - break; - default: - nameColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); - break; - } + ImVec4 nameColor = hostileColor; ImGui::TextColored(nameColor, "%s", name.c_str()); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 973577bf..823fe0b9 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1,4 +1,5 @@ #include "ui/inventory_screen.hpp" +#include "game/game_handler.hpp" #include "core/input.hpp" #include #include @@ -32,13 +33,11 @@ game::EquipSlot InventoryScreen::getEquipSlotForType(uint8_t inventoryType, game case 9: return game::EquipSlot::WRISTS; case 10: return game::EquipSlot::HANDS; case 11: { - // Ring: prefer empty slot, else RING1 if (inv.getEquipSlot(game::EquipSlot::RING1).empty()) return game::EquipSlot::RING1; return game::EquipSlot::RING2; } case 12: { - // Trinket: prefer empty slot, else TRINKET1 if (inv.getEquipSlot(game::EquipSlot::TRINKET1).empty()) return game::EquipSlot::TRINKET1; return game::EquipSlot::TRINKET2; @@ -99,7 +98,6 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { game::ItemDef targetItem = target.item; inv.setBackpackSlot(index, heldItem); heldItem = targetItem; - // Keep holding the swapped item - update source to this backpack slot heldSource = HeldSource::BACKPACK; heldBackpackIndex = index; } @@ -112,19 +110,18 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo // Validate: check if the held item can go in this slot if (heldItem.inventoryType > 0) { game::EquipSlot validSlot = getEquipSlotForType(heldItem.inventoryType, inv); - if (validSlot == game::EquipSlot::NUM_SLOTS) return; // Not equippable + if (validSlot == game::EquipSlot::NUM_SLOTS) return; - // For rings/trinkets, allow either slot bool valid = (slot == validSlot); if (!valid) { - if (heldItem.inventoryType == 11) // Ring + if (heldItem.inventoryType == 11) valid = (slot == game::EquipSlot::RING1 || slot == game::EquipSlot::RING2); - else if (heldItem.inventoryType == 12) // Trinket + else if (heldItem.inventoryType == 12) valid = (slot == game::EquipSlot::TRINKET1 || slot == game::EquipSlot::TRINKET2); } if (!valid) return; } else { - return; // No inventoryType means not equippable + return; } const auto& target = inv.getEquipSlot(slot); @@ -132,7 +129,6 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo inv.setEquipSlot(slot, heldItem); holdingItem = false; } else { - // Swap game::ItemDef targetItem = target.item; inv.setEquipSlot(slot, heldItem); heldItem = targetItem; @@ -163,13 +159,10 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo void InventoryScreen::cancelPickup(game::Inventory& inv) { if (!holdingItem) return; - // Return item to source if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) { - // If source slot is still empty, put it back if (inv.getBackpackSlot(heldBackpackIndex).empty()) { inv.setBackpackSlot(heldBackpackIndex, heldItem); } else { - // Source was swapped into; find free slot inv.addItem(heldItem); } } else if (heldSource == HeldSource::EQUIPMENT && heldEquipSlot != game::EquipSlot::NUM_SLOTS) { @@ -180,7 +173,6 @@ void InventoryScreen::cancelPickup(game::Inventory& inv) { inv.addItem(heldItem); } } else { - // Fallback: just add to inventory inv.addItem(heldItem); } holdingItem = false; @@ -199,13 +191,11 @@ void InventoryScreen::renderHeldItem() { ImVec4 qColor = getQualityColor(heldItem.quality); ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor); - // Background 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); - // Item abbreviation char abbr[4] = {}; if (!heldItem.name.empty()) { abbr[0] = heldItem.name[0]; @@ -215,7 +205,6 @@ void InventoryScreen::renderHeldItem() { drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), ImGui::ColorConvertFloat4ToU32(qColor), abbr); - // Stack count if (heldItem.stackCount > 1) { char countStr[16]; snprintf(countStr, sizeof(countStr), "%u", heldItem.stackCount); @@ -225,6 +214,10 @@ void InventoryScreen::renderHeldItem() { } } +// ============================================================ +// Bags window (B key) — bottom of screen, no equipment panel +// ============================================================ + void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { // B key toggle (edge-triggered) bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; @@ -234,8 +227,14 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { } bKeyWasDown = bDown; + // C key toggle for character screen (edge-triggered) + bool cDown = !uiWantsKeyboard && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_C); + if (cDown && !cKeyWasDown) { + characterOpen = !characterOpen; + } + cKeyWasDown = cDown; + if (!open) { - // Cancel held item if inventory closes if (holdingItem) cancelPickup(inventory); return; } @@ -252,33 +251,42 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGuiIO& io = ImGui::GetIO(); float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; - // Position inventory window on the right side of the screen - ImGui::SetNextWindowPos(ImVec2(screenW - 520.0f, 80.0f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(500.0f, 560.0f), ImGuiCond_FirstUseEver); + // Calculate bag window size + constexpr float slotSize = 40.0f; + constexpr int columns = 4; + int rows = (inventory.getBackpackSize() + columns - 1) / columns; + float bagContentH = rows * (slotSize + 4.0f) + 40.0f; // slots + header + money - ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; - if (!ImGui::Begin("Inventory", &open, flags)) { + // Check for extra bags and add space + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { + int bagSize = inventory.getBagSize(bag); + if (bagSize <= 0) continue; + int bagRows = (bagSize + columns - 1) / columns; + bagContentH += bagRows * (slotSize + 4.0f) + 30.0f; // slots + header + } + + float windowW = columns * (slotSize + 4.0f) + 30.0f; + float windowH = bagContentH + 50.0f; // padding + + // Position at bottom-right of screen + float posX = screenW - windowW - 10.0f; + float posY = screenH - windowH - 60.0f; // above action bar area + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; + if (!ImGui::Begin("Bags", &open, flags)) { ImGui::End(); return; } - // Reserve space for money display at bottom - float moneyHeight = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; - float panelHeight = ImGui::GetContentRegionAvail().y - moneyHeight; - - // Two-column layout: Equipment (left) | Backpack (right) - ImGui::BeginChild("EquipPanel", ImVec2(200.0f, panelHeight), true); - renderEquipmentPanel(inventory); - ImGui::EndChild(); - - ImGui::SameLine(); - - ImGui::BeginChild("BackpackPanel", ImVec2(0.0f, panelHeight), true); renderBackpackPanel(inventory); - ImGui::EndChild(); // Money display + ImGui::Spacing(); uint64_t gold = moneyCopper / 10000; uint64_t silver = (moneyCopper / 100) % 100; uint64_t copper = moneyCopper % 100; @@ -288,10 +296,37 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { static_cast(copper)); ImGui::End(); - // Draw held item at cursor (on top of everything) + // Draw held item at cursor renderHeldItem(); } +// ============================================================ +// Character screen (C key) — standalone equipment window +// ============================================================ + +void InventoryScreen::renderCharacterScreen(game::Inventory& inventory) { + if (!characterOpen) return; + + ImGui::SetNextWindowPos(ImVec2(20.0f, 80.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(220.0f, 520.0f), ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + if (!ImGui::Begin("Character", &characterOpen, flags)) { + ImGui::End(); + return; + } + + renderEquipmentPanel(inventory); + + ImGui::End(); + + // If both bags and character are open, allow drag-and-drop between them + // (held item rendering is handled in render()) + if (open) { + renderHeldItem(); + } +} + void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Equipment"); ImGui::Separator(); @@ -312,10 +347,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { constexpr float slotSize = 36.0f; constexpr float spacing = 4.0f; - // Two columns of equipment int rows = 8; for (int r = 0; r < rows; r++) { - // Left slot { const auto& slot = inventory.getEquipSlot(leftSlots[r]); const char* label = game::getEquipSlotName(leftSlots[r]); @@ -329,7 +362,6 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { ImGui::SameLine(slotSize + spacing + 60.0f); - // Right slot { const auto& slot = inventory.getEquipSlot(rightSlots[r]); const char* label = game::getEquipSlotName(rightSlots[r]); @@ -420,7 +452,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite bool validDrop = false; if (holdingItem) { if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { - validDrop = true; // Can always drop in backpack + validDrop = true; } else if (kind == SlotKind::EQUIPMENT && heldItem.inventoryType > 0) { game::EquipSlot validSlot = getEquipSlotForType(heldItem.inventoryType, inventory); validDrop = (equipSlot == validSlot); @@ -432,11 +464,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } if (isEmpty) { - // Empty slot: dark grey background ImU32 bgCol = IM_COL32(30, 30, 30, 200); ImU32 borderCol = IM_COL32(60, 60, 60, 200); - // Highlight valid drop targets if (validDrop) { bgCol = IM_COL32(20, 50, 20, 200); borderCol = IM_COL32(0, 180, 0, 200); @@ -445,7 +475,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), bgCol); drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), borderCol); - // Slot label for equipment slots if (label) { char abbr[4] = {}; abbr[0] = label[0]; @@ -457,7 +486,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite ImGui::InvisibleButton("slot", ImVec2(size, size)); - // Click interactions if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && holdingItem && validDrop) { if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { placeInBackpack(inventory, backpackIndex); @@ -466,7 +494,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } - // Tooltip for empty equip slots if (label && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "%s", label); @@ -478,7 +505,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite ImVec4 qColor = getQualityColor(item.quality); ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor); - // Highlight valid drop targets with green tint ImU32 bgCol = IM_COL32(40, 35, 30, 220); if (holdingItem && validDrop) { bgCol = IM_COL32(30, 55, 30, 220); @@ -489,7 +515,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), borderCol, 0.0f, 0, 2.0f); - // Item abbreviation (first 2 letters) char abbr[4] = {}; if (!item.name.empty()) { abbr[0] = item.name[0]; @@ -499,7 +524,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f), ImGui::ColorConvertFloat4ToU32(qColor), abbr); - // Stack count (bottom-right) if (item.stackCount > 1) { char countStr[16]; snprintf(countStr, sizeof(countStr), "%u", item.stackCount); @@ -513,14 +537,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite // Left-click: pickup or place/swap if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { if (!holdingItem) { - // Pick up this item if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { pickupFromBackpack(inventory, backpackIndex); } else if (kind == SlotKind::EQUIPMENT) { pickupFromEquipment(inventory, equipSlot); } } else { - // Holding an item - place or swap if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { placeInBackpack(inventory, backpackIndex); } else if (kind == SlotKind::EQUIPMENT && validDrop) { @@ -529,9 +551,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } - // Right-click: auto-equip from backpack, or unequip from equipment + // Right-click: vendor sell (if vendor mode) or auto-equip/unequip if (ImGui::IsItemClicked(ImGuiMouseButton_Right) && !holdingItem) { - if (kind == SlotKind::EQUIPMENT) { + if (vendorMode_ && gameHandler_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + // Sell to vendor + gameHandler_->sellItemBySlot(backpackIndex); + } else if (kind == SlotKind::EQUIPMENT) { // Unequip: move to free backpack slot int freeSlot = inventory.findFreeBackpackSlot(); if (freeSlot >= 0) { @@ -541,8 +566,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite inventoryDirty = true; } } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0 && item.inventoryType > 0) { - // Auto-equip: find the right slot - // Capture type before swap (item ref may become stale) + // Auto-equip uint8_t equippingType = item.inventoryType; game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory); if (targetSlot != game::EquipSlot::NUM_SLOTS) { @@ -551,12 +575,10 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite inventory.setEquipSlot(targetSlot, item); inventory.clearBackpackSlot(backpackIndex); } else { - // Swap with equipped item game::ItemDef equippedItem = eqSlot.item; inventory.setEquipSlot(targetSlot, item); inventory.setBackpackSlot(backpackIndex, equippedItem); } - // Two-handed weapon in main hand clears the off-hand if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) { const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND); if (!offHand.empty()) { @@ -564,7 +586,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite inventory.clearEquipSlot(game::EquipSlot::OFF_HAND); } } - // Equipping off-hand unequips a 2H weapon from main hand if (targetSlot == game::EquipSlot::OFF_HAND && inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) { inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item); @@ -645,6 +666,18 @@ 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); + } + } + ImGui::EndTooltip(); } diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp new file mode 100644 index 00000000..14894d12 --- /dev/null +++ b/src/ui/quest_log_screen.cpp @@ -0,0 +1,90 @@ +#include "ui/quest_log_screen.hpp" +#include "core/application.hpp" +#include "core/input.hpp" +#include + +namespace wowee { namespace ui { + +void QuestLogScreen::render(game::GameHandler& gameHandler) { + // L key toggle (edge-triggered) + bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; + bool lDown = !uiWantsKeyboard && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L); + if (lDown && !lKeyWasDown) { + open = !open; + } + lKeyWasDown = lDown; + + if (!open) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float logW = 380.0f; + float logH = std::min(450.0f, screenH - 120.0f); + float logX = (screenW - logW) * 0.5f; + float logY = 80.0f; + + ImGui::SetNextWindowPos(ImVec2(logX, logY), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(logW, logH), ImGuiCond_FirstUseEver); + + bool stillOpen = true; + if (ImGui::Begin("Quest Log", &stillOpen)) { + const auto& quests = gameHandler.getQuestLog(); + + if (quests.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active quests."); + } else { + // Left panel: quest list + ImGui::BeginChild("QuestList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4), true); + for (size_t i = 0; i < quests.size(); i++) { + const auto& q = quests[i]; + ImGui::PushID(static_cast(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(i)); + if (ImGui::Selectable("##quest", selected, 0, ImVec2(0, 20))) { + selectedIndex = static_cast(i); + } + ImGui::SameLine(); + ImGui::TextColored(color, "%s%s", + q.title.c_str(), + q.complete ? " (Complete)" : ""); + + ImGui::PopID(); + } + ImGui::EndChild(); + + // Details panel for selected quest + if (selectedIndex >= 0 && selectedIndex < static_cast(quests.size())) { + const auto& sel = quests[static_cast(selectedIndex)]; + + if (!sel.objectives.empty()) { + ImGui::Separator(); + ImGui::TextWrapped("%s", sel.objectives.c_str()); + } + + // Abandon button + if (!sel.complete) { + ImGui::Separator(); + if (ImGui::Button("Abandon Quest")) { + gameHandler.abandonQuest(sel.questId); + if (selectedIndex >= static_cast(quests.size())) { + selectedIndex = static_cast(quests.size()) - 1; + } + } + } + } + } + } + ImGui::End(); + + if (!stillOpen) { + open = false; + } +} + +}} // namespace wowee::ui