From c49bb58e478e6841b39f59e6c68636908c420d4a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 4 Feb 2026 10:30:52 -0800 Subject: [PATCH] Add gameplay systems: combat, spells, groups, loot, vendors, and UI Implement ~70 new protocol opcodes across 5 phases while maintaining full 3.3.5a private server compatibility: - Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature name queries, CMSG_SET_ACTIVE_MOVER after login - Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power tracking from UPDATE_OBJECT fields, floating combat text - Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar, cooldown tracking, aura/buff system with cancellation - Phase 4: Group invite/accept/decline/leave, party frames UI, /invite chat command - Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface Also: disable debug HUD/panels by default, gate 3D rendering to IN_GAME state only, fix window resize not updating UI positions. --- CMakeLists.txt | 4 + include/core/window.hpp | 1 + include/game/entity.hpp | 18 + include/game/game_handler.hpp | 158 ++++++ include/game/group_defines.hpp | 72 +++ include/game/opcodes.hpp | 158 ++++-- include/game/spell_defines.hpp | 69 +++ include/game/world_packets.hpp | 535 +++++++++++++++++- include/rendering/performance_hud.hpp | 2 +- include/ui/game_screen.hpp | 15 +- src/core/application.cpp | 29 +- src/game/game_handler.cpp | 747 ++++++++++++++++++++++++++ src/game/world_packets.cpp | 637 ++++++++++++++++++++++ src/ui/game_screen.cpp | 678 ++++++++++++++++++++++- 14 files changed, 3039 insertions(+), 84 deletions(-) create mode 100644 include/game/group_defines.hpp create mode 100644 include/game/spell_defines.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3cf0c4a3..57dfb17b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,10 @@ set(WOWEE_HEADERS include/game/zone_manager.hpp include/game/npc_manager.hpp include/game/inventory.hpp + include/game/spell_defines.hpp + include/game/group_defines.hpp + include/game/world_packets.hpp + include/game/character.hpp include/audio/music_manager.hpp include/audio/footstep_manager.hpp diff --git a/include/core/window.hpp b/include/core/window.hpp index a63d88c2..588297e9 100644 --- a/include/core/window.hpp +++ b/include/core/window.hpp @@ -35,6 +35,7 @@ public: int getWidth() const { return width; } int getHeight() const { return height; } + void setSize(int w, int h) { width = w; height = h; } float getAspectRatio() const { return static_cast(width) / height; } SDL_Window* getSDLWindow() const { return window; } diff --git a/include/game/entity.hpp b/include/game/entity.hpp index b882e37f..0de47d8d 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -129,15 +129,33 @@ public: uint32_t getMaxHealth() const { return maxHealth; } void setMaxHealth(uint32_t h) { maxHealth = h; } + // Power (mana/rage/energy) + uint32_t getPower() const { return power; } + void setPower(uint32_t p) { power = p; } + + uint32_t getMaxPower() const { return maxPower; } + void setMaxPower(uint32_t p) { maxPower = p; } + + uint8_t getPowerType() const { return powerType; } + void setPowerType(uint8_t t) { powerType = t; } + // Level uint32_t getLevel() const { return level; } void setLevel(uint32_t l) { level = l; } + // Entry ID (creature template entry) + uint32_t getEntry() const { return entry; } + void setEntry(uint32_t e) { entry = e; } + protected: std::string name; uint32_t health = 0; uint32_t maxHealth = 0; + uint32_t power = 0; + uint32_t maxPower = 0; + uint8_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy uint32_t level = 1; + uint32_t entry = 0; }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f9a36068..4accefc9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -3,11 +3,16 @@ #include "game/world_packets.hpp" #include "game/character.hpp" #include "game/inventory.hpp" +#include "game/spell_defines.hpp" +#include "game/group_defines.hpp" #include #include #include +#include #include #include +#include +#include namespace wowee { namespace network { class WorldSocket; class Packet; } @@ -165,6 +170,77 @@ public: bool hasTarget() const { return targetGuid != 0; } void tabTarget(float playerX, float playerY, float playerZ); + // ---- Phase 1: Name queries ---- + void queryPlayerName(uint64_t guid); + void queryCreatureInfo(uint32_t entry, uint64_t guid); + std::string getCachedPlayerName(uint64_t guid) const; + std::string getCachedCreatureName(uint32_t entry) const; + + // ---- Phase 2: Combat ---- + void startAutoAttack(uint64_t targetGuid); + void stopAutoAttack(); + bool isAutoAttacking() const { return autoAttacking; } + const std::vector& getCombatText() const { return combatText; } + void updateCombatText(float deltaTime); + + // ---- Phase 3: Spells ---- + void castSpell(uint32_t spellId, uint64_t targetGuid = 0); + void cancelCast(); + void cancelAura(uint32_t spellId); + const std::vector& getKnownSpells() const { return knownSpells; } + bool isCasting() const { return casting; } + uint32_t getCurrentCastSpellId() const { return currentCastSpellId; } + float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } + float getCastTimeRemaining() const { return castTimeRemaining; } + + // Action bar + static constexpr int ACTION_BAR_SLOTS = 12; + std::array& getActionBar() { return actionBar; } + const std::array& getActionBar() const { return actionBar; } + void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); + + // Auras + const std::vector& getPlayerAuras() const { return playerAuras; } + const std::vector& getTargetAuras() const { return targetAuras; } + + // Cooldowns + float getSpellCooldown(uint32_t spellId) const; + + // Player GUID + uint64_t getPlayerGuid() const { return playerGuid; } + void setPlayerGuid(uint64_t guid) { playerGuid = guid; } + + // ---- Phase 4: Group ---- + void inviteToGroup(const std::string& playerName); + void acceptGroupInvite(); + void declineGroupInvite(); + void leaveGroup(); + bool isInGroup() const { return !partyData.isEmpty(); } + const GroupListData& getPartyData() const { return partyData; } + bool hasPendingGroupInvite() const { return pendingGroupInvite; } + const std::string& getPendingInviterName() const { return pendingInviterName; } + + // ---- Phase 5: Loot ---- + void lootTarget(uint64_t guid); + void lootItem(uint8_t slotIndex); + void closeLoot(); + bool isLootWindowOpen() const { return lootWindowOpen; } + const LootResponseData& getCurrentLoot() const { return currentLoot; } + + // NPC Gossip + void interactWithNpc(uint64_t guid); + void selectGossipOption(uint32_t optionId); + void closeGossip(); + bool isGossipWindowOpen() const { return gossipWindowOpen; } + const GossipMessageData& getCurrentGossip() const { return currentGossip; } + + // Vendor + void openVendor(uint64_t npcGuid); + 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); + bool isVendorWindowOpen() const { return vendorWindowOpen; } + const ListInventoryData& getVendorItems() const { return currentVendorItems; } + /** * Set callbacks */ @@ -234,6 +310,45 @@ private: */ void handleMessageChat(network::Packet& packet); + // ---- Phase 1 handlers ---- + void handleNameQueryResponse(network::Packet& packet); + void handleCreatureQueryResponse(network::Packet& packet); + + // ---- Phase 2 handlers ---- + void handleAttackStart(network::Packet& packet); + void handleAttackStop(network::Packet& packet); + void handleAttackerStateUpdate(network::Packet& packet); + void handleSpellDamageLog(network::Packet& packet); + void handleSpellHealLog(network::Packet& packet); + + // ---- Phase 3 handlers ---- + void handleInitialSpells(network::Packet& packet); + void handleCastFailed(network::Packet& packet); + void handleSpellStart(network::Packet& packet); + void handleSpellGo(network::Packet& packet); + void handleSpellCooldown(network::Packet& packet); + void handleCooldownEvent(network::Packet& packet); + void handleAuraUpdate(network::Packet& packet, bool isAll); + void handleLearnedSpell(network::Packet& packet); + void handleRemovedSpell(network::Packet& packet); + + // ---- Phase 4 handlers ---- + void handleGroupInvite(network::Packet& packet); + void handleGroupDecline(network::Packet& packet); + void handleGroupList(network::Packet& packet); + void handleGroupUninvite(network::Packet& packet); + void handlePartyCommandResult(network::Packet& packet); + + // ---- Phase 5 handlers ---- + void handleLootResponse(network::Packet& packet); + void handleLootReleaseResponse(network::Packet& packet); + void handleLootRemoved(network::Packet& packet); + void handleGossipMessage(network::Packet& packet); + void handleGossipComplete(network::Packet& packet); + void handleListInventory(network::Packet& packet); + + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource); + /** * Send CMSG_PING to server (heartbeat) */ @@ -301,6 +416,49 @@ private: float pingInterval = 30.0f; // Ping interval (30 seconds) uint32_t lastLatency = 0; // Last measured latency (milliseconds) + // Player GUID + uint64_t playerGuid = 0; + + // ---- Phase 1: Name caches ---- + std::unordered_map playerNameCache; + std::unordered_set pendingNameQueries; + std::unordered_map creatureInfoCache; + std::unordered_set pendingCreatureQueries; + + // ---- Phase 2: Combat ---- + bool autoAttacking = false; + uint64_t autoAttackTarget = 0; + std::vector combatText; + + // ---- Phase 3: Spells ---- + std::vector knownSpells; + std::unordered_map spellCooldowns; // spellId -> remaining seconds + uint8_t castCount = 0; + bool casting = false; + uint32_t currentCastSpellId = 0; + float castTimeRemaining = 0.0f; + float castTimeTotal = 0.0f; + std::array actionBar{}; + std::vector playerAuras; + std::vector targetAuras; + + // ---- Phase 4: Group ---- + GroupListData partyData; + bool pendingGroupInvite = false; + std::string pendingInviterName; + + // ---- Phase 5: Loot ---- + bool lootWindowOpen = false; + LootResponseData currentLoot; + + // Gossip + bool gossipWindowOpen = false; + GossipMessageData currentGossip; + + // Vendor + bool vendorWindowOpen = false; + ListInventoryData currentVendorItems; + // Callbacks WorldConnectSuccessCallback onSuccess; WorldConnectFailureCallback onFailure; diff --git a/include/game/group_defines.hpp b/include/game/group_defines.hpp new file mode 100644 index 00000000..60f1a9f0 --- /dev/null +++ b/include/game/group_defines.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace game { + +/** + * Party/Group member data + */ +struct GroupMember { + std::string name; + uint64_t guid = 0; + uint8_t isOnline = 0; // 0 = offline, 1 = online + uint8_t subGroup = 0; // Raid subgroup (0 for party) + uint8_t flags = 0; // Assistant, main tank, etc. + uint8_t roles = 0; // LFG roles (3.3.5a) +}; + +/** + * Full group/party data from SMSG_GROUP_LIST + */ +struct GroupListData { + uint8_t groupType = 0; // 0 = party, 1 = raid + uint8_t subGroup = 0; + uint8_t flags = 0; + uint8_t roles = 0; + uint8_t lootMethod = 0; // 0=free for all, 1=round robin, 2=master loot + uint64_t looterGuid = 0; + uint8_t lootThreshold = 0; + uint8_t difficultyId = 0; + uint8_t raidDifficultyId = 0; + uint32_t memberCount = 0; + std::vector members; + uint64_t leaderGuid = 0; + + bool isValid() const { return true; } + bool isEmpty() const { return memberCount == 0; } +}; + +/** + * Party command types + */ +enum class PartyCommand : uint32_t { + INVITE = 0, + UNINVITE = 1, + LEAVE = 2, + SWAP = 3 +}; + +/** + * Party command result codes + */ +enum class PartyResult : uint32_t { + OK = 0, + BAD_PLAYER_NAME = 1, + TARGET_NOT_IN_GROUP = 2, + TARGET_NOT_IN_INSTANCE = 3, + GROUP_FULL = 4, + ALREADY_IN_GROUP = 5, + NOT_IN_GROUP = 6, + NOT_LEADER = 7, + PLAYER_WRONG_FACTION = 8, + IGNORING_YOU = 9, + LFG_PENDING = 12, + INVITE_RESTRICTED = 13 +}; + +} // namespace game +} // namespace wowee diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index df090e1e..8bbc199e 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -9,48 +9,132 @@ namespace game { // Values derived from community reverse-engineering efforts // Reference: https://wowdev.wiki/World_Packet enum class Opcode : uint16_t { - // Client to Server - CMSG_PING = 0x1DC, - CMSG_AUTH_SESSION = 0x1ED, - CMSG_CHAR_ENUM = 0x037, - CMSG_PLAYER_LOGIN = 0x03D, + // ---- Client to Server (Core) ---- + CMSG_PING = 0x1DC, + CMSG_AUTH_SESSION = 0x1ED, + CMSG_CHAR_ENUM = 0x037, + CMSG_PLAYER_LOGIN = 0x03D, - // Movement - CMSG_MOVE_START_FORWARD = 0x0B5, - CMSG_MOVE_START_BACKWARD = 0x0B6, - CMSG_MOVE_STOP = 0x0B7, - CMSG_MOVE_START_STRAFE_LEFT = 0x0B8, - CMSG_MOVE_START_STRAFE_RIGHT = 0x0B9, - CMSG_MOVE_STOP_STRAFE = 0x0BA, - CMSG_MOVE_JUMP = 0x0BB, - CMSG_MOVE_START_TURN_LEFT = 0x0BC, - CMSG_MOVE_START_TURN_RIGHT = 0x0BD, - CMSG_MOVE_STOP_TURN = 0x0BE, - CMSG_MOVE_SET_FACING = 0x0DA, - CMSG_MOVE_FALL_LAND = 0x0C9, - CMSG_MOVE_START_SWIM = 0x0CA, - CMSG_MOVE_STOP_SWIM = 0x0CB, - CMSG_MOVE_HEARTBEAT = 0x0EE, + // ---- Movement ---- + CMSG_MOVE_START_FORWARD = 0x0B5, + CMSG_MOVE_START_BACKWARD = 0x0B6, + CMSG_MOVE_STOP = 0x0B7, + CMSG_MOVE_START_STRAFE_LEFT = 0x0B8, + CMSG_MOVE_START_STRAFE_RIGHT = 0x0B9, + CMSG_MOVE_STOP_STRAFE = 0x0BA, + CMSG_MOVE_JUMP = 0x0BB, + CMSG_MOVE_START_TURN_LEFT = 0x0BC, + CMSG_MOVE_START_TURN_RIGHT = 0x0BD, + CMSG_MOVE_STOP_TURN = 0x0BE, + CMSG_MOVE_SET_FACING = 0x0DA, + CMSG_MOVE_FALL_LAND = 0x0C9, + CMSG_MOVE_START_SWIM = 0x0CA, + CMSG_MOVE_STOP_SWIM = 0x0CB, + CMSG_MOVE_HEARTBEAT = 0x0EE, - // Server to Client - SMSG_AUTH_CHALLENGE = 0x1EC, - SMSG_AUTH_RESPONSE = 0x1EE, - SMSG_CHAR_ENUM = 0x03B, - SMSG_PONG = 0x1DD, - SMSG_LOGIN_VERIFY_WORLD = 0x236, - SMSG_ACCOUNT_DATA_TIMES = 0x209, - SMSG_FEATURE_SYSTEM_STATUS = 0x3ED, - SMSG_MOTD = 0x33D, + // ---- Server to Client (Core) ---- + SMSG_AUTH_CHALLENGE = 0x1EC, + SMSG_AUTH_RESPONSE = 0x1EE, + SMSG_CHAR_ENUM = 0x03B, + SMSG_PONG = 0x1DD, + SMSG_LOGIN_VERIFY_WORLD = 0x236, + SMSG_ACCOUNT_DATA_TIMES = 0x209, + SMSG_FEATURE_SYSTEM_STATUS = 0x3ED, + SMSG_MOTD = 0x33D, - // Entity/Object updates - SMSG_UPDATE_OBJECT = 0x0A9, - SMSG_DESTROY_OBJECT = 0x0AA, + // ---- Entity/Object updates ---- + SMSG_UPDATE_OBJECT = 0x0A9, + SMSG_DESTROY_OBJECT = 0x0AA, - // Chat - CMSG_MESSAGECHAT = 0x095, - SMSG_MESSAGECHAT = 0x096, + // ---- Chat ---- + CMSG_MESSAGECHAT = 0x095, + SMSG_MESSAGECHAT = 0x096, - // TODO: Add more opcodes as needed + // ---- Phase 1: Foundation (Targeting, Queries) ---- + CMSG_SET_SELECTION = 0x13D, + CMSG_NAME_QUERY = 0x050, + SMSG_NAME_QUERY_RESPONSE = 0x051, + CMSG_CREATURE_QUERY = 0x060, + SMSG_CREATURE_QUERY_RESPONSE = 0x061, + CMSG_GAMEOBJECT_QUERY = 0x05E, + SMSG_GAMEOBJECT_QUERY_RESPONSE = 0x05F, + CMSG_SET_ACTIVE_MOVER = 0x26A, + + // ---- Phase 2: Combat Core ---- + CMSG_ATTACKSWING = 0x141, + CMSG_ATTACKSTOP = 0x142, + SMSG_ATTACKSTART = 0x143, + SMSG_ATTACKSTOP = 0x144, + SMSG_ATTACKERSTATEUPDATE = 0x14A, + SMSG_SPELLNONMELEEDAMAGELOG = 0x250, + SMSG_SPELLHEALLOG = 0x150, + SMSG_SPELLENERGIZELOG = 0x25B, + SMSG_PERIODICAURALOG = 0x24E, + SMSG_ENVIRONMENTALDAMAGELOG = 0x1FC, + + // ---- Phase 3: Spells, Action Bar, Auras ---- + CMSG_CAST_SPELL = 0x12E, + CMSG_CANCEL_CAST = 0x12F, + CMSG_CANCEL_AURA = 0x033, + SMSG_CAST_FAILED = 0x130, + SMSG_SPELL_START = 0x131, + SMSG_SPELL_GO = 0x132, + SMSG_SPELL_FAILURE = 0x133, + SMSG_SPELL_COOLDOWN = 0x134, + SMSG_COOLDOWN_EVENT = 0x135, + SMSG_UPDATE_AURA_DURATION = 0x137, + SMSG_INITIAL_SPELLS = 0x12A, + SMSG_LEARNED_SPELL = 0x12B, + SMSG_REMOVED_SPELL = 0x203, + SMSG_SPELL_DELAYED = 0x1E2, + SMSG_AURA_UPDATE = 0x3FA, + SMSG_AURA_UPDATE_ALL = 0x495, + SMSG_SET_FLAT_SPELL_MODIFIER = 0x266, + SMSG_SET_PCT_SPELL_MODIFIER = 0x267, + + // ---- Phase 4: Group/Party ---- + CMSG_GROUP_INVITE = 0x06E, + SMSG_GROUP_INVITE = 0x06F, + CMSG_GROUP_ACCEPT = 0x072, + CMSG_GROUP_DECLINE = 0x073, + SMSG_GROUP_DECLINE = 0x074, + CMSG_GROUP_UNINVITE_GUID = 0x076, + SMSG_GROUP_UNINVITE = 0x077, + CMSG_GROUP_SET_LEADER = 0x078, + SMSG_GROUP_SET_LEADER = 0x079, + CMSG_GROUP_DISBAND = 0x07B, + SMSG_GROUP_LIST = 0x07D, + SMSG_PARTY_COMMAND_RESULT = 0x07E, + MSG_RAID_TARGET_UPDATE = 0x321, + + // ---- Phase 5: Loot ---- + CMSG_LOOT = 0x15D, + CMSG_LOOT_RELEASE = 0x15E, + SMSG_LOOT_RESPONSE = 0x160, + SMSG_LOOT_RELEASE_RESPONSE = 0x161, + CMSG_AUTOSTORE_LOOT_ITEM = 0x162, + SMSG_LOOT_REMOVED = 0x163, + SMSG_LOOT_MONEY_NOTIFY = 0x164, + SMSG_LOOT_CLEAR_MONEY = 0x165, + + // ---- Phase 5: NPC Gossip ---- + CMSG_GOSSIP_HELLO = 0x17C, + SMSG_GOSSIP_MESSAGE = 0x17D, + CMSG_GOSSIP_SELECT_OPTION = 0x17E, + SMSG_GOSSIP_COMPLETE = 0x17F, + SMSG_NPC_TEXT_UPDATE = 0x180, + + // ---- Phase 5: Vendor ---- + CMSG_LIST_INVENTORY = 0x19E, + SMSG_LIST_INVENTORY = 0x19F, + CMSG_SELL_ITEM = 0x1A0, + SMSG_SELL_ITEM = 0x1A1, + CMSG_BUY_ITEM = 0x1A2, + SMSG_BUY_FAILED = 0x1A5, + + // ---- Phase 5: Item/Equip ---- + CMSG_AUTOEQUIP_ITEM = 0x10A, + SMSG_INVENTORY_CHANGE_FAILURE = 0x112, }; } // namespace game diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp new file mode 100644 index 00000000..529e0e8b --- /dev/null +++ b/include/game/spell_defines.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace game { + +/** + * Aura slot data for buff/debuff tracking + */ +struct AuraSlot { + uint32_t spellId = 0; + uint8_t flags = 0; // Active, positive/negative, etc. + uint8_t level = 0; + uint8_t charges = 0; + int32_t durationMs = -1; + int32_t maxDurationMs = -1; + uint64_t casterGuid = 0; + + bool isEmpty() const { return spellId == 0; } +}; + +/** + * Action bar slot + */ +struct ActionBarSlot { + enum Type : uint8_t { EMPTY = 0, SPELL = 1, ITEM = 2, MACRO = 3 }; + Type type = EMPTY; + uint32_t id = 0; // spellId, itemId, or macroId + float cooldownRemaining = 0.0f; + float cooldownTotal = 0.0f; + + bool isReady() const { return cooldownRemaining <= 0.0f; } + bool isEmpty() const { return type == EMPTY; } +}; + +/** + * Floating combat text entry + */ +struct CombatTextEntry { + enum Type : uint8_t { + MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, + CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL + }; + Type type; + int32_t amount = 0; + uint32_t spellId = 0; + float age = 0.0f; // Seconds since creation (for fadeout) + bool isPlayerSource = false; // True if player dealt this + + static constexpr float LIFETIME = 2.5f; + bool isExpired() const { return age >= LIFETIME; } +}; + +/** + * Spell cooldown entry received from server + */ +struct SpellCooldownEntry { + uint32_t spellId; + uint16_t itemId; + uint16_t categoryId; + uint32_t cooldownMs; + uint32_t categoryCooldownMs; +}; + +} // namespace game +} // namespace wowee diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 1c96535a..eddbba26 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -3,10 +3,13 @@ #include "network/packet.hpp" #include "game/opcodes.hpp" #include "game/entity.hpp" +#include "game/spell_defines.hpp" +#include "game/group_defines.hpp" #include #include #include #include +#include namespace wowee { namespace game { @@ -397,6 +400,14 @@ public: */ static bool parse(network::Packet& packet, UpdateObjectData& data); + /** + * Read packed GUID from packet + * + * @param packet Packet to read from + * @return GUID value + */ + static uint64_t readPackedGuid(network::Packet& packet); + private: /** * Parse a single update block @@ -424,14 +435,6 @@ private: * @return true if successful */ static bool parseUpdateFields(network::Packet& packet, UpdateBlock& block); - - /** - * Read packed GUID from packet - * - * @param packet Packet to read from - * @return GUID value - */ - static uint64_t readPackedGuid(network::Packet& packet); }; /** @@ -562,5 +565,521 @@ public: */ const char* getChatTypeString(ChatType type); +// ============================================================ +// Phase 1: Foundation — Targeting, Name Queries +// ============================================================ + +/** CMSG_SET_SELECTION packet builder */ +class SetSelectionPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + +/** CMSG_SET_ACTIVE_MOVER packet builder */ +class SetActiveMoverPacket { +public: + static network::Packet build(uint64_t guid); +}; + +/** CMSG_NAME_QUERY packet builder */ +class NameQueryPacket { +public: + static network::Packet build(uint64_t playerGuid); +}; + +/** SMSG_NAME_QUERY_RESPONSE data */ +struct NameQueryResponseData { + uint64_t guid = 0; + uint8_t found = 1; // 0 = found, 1 = not found + std::string name; + std::string realmName; + uint8_t race = 0; + uint8_t gender = 0; + uint8_t classId = 0; + + bool isValid() const { return found == 0 && !name.empty(); } +}; + +/** SMSG_NAME_QUERY_RESPONSE parser */ +class NameQueryResponseParser { +public: + static bool parse(network::Packet& packet, NameQueryResponseData& data); +}; + +/** CMSG_CREATURE_QUERY packet builder */ +class CreatureQueryPacket { +public: + static network::Packet build(uint32_t entry, uint64_t guid); +}; + +/** SMSG_CREATURE_QUERY_RESPONSE data */ +struct CreatureQueryResponseData { + uint32_t entry = 0; + std::string name; + std::string subName; + std::string iconName; + uint32_t typeFlags = 0; + uint32_t creatureType = 0; + uint32_t family = 0; + uint32_t rank = 0; // 0=Normal, 1=Elite, 2=Rare Elite, 3=Boss, 4=Rare + + bool isValid() const { return entry != 0 && !name.empty(); } +}; + +/** SMSG_CREATURE_QUERY_RESPONSE parser */ +class CreatureQueryResponseParser { +public: + static bool parse(network::Packet& packet, CreatureQueryResponseData& data); +}; + +// ============================================================ +// Phase 2: Combat Core +// ============================================================ + +/** CMSG_ATTACKSWING packet builder */ +class AttackSwingPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + +/** CMSG_ATTACKSTOP packet builder */ +class AttackStopPacket { +public: + static network::Packet build(); +}; + +/** SMSG_ATTACKSTART data */ +struct AttackStartData { + uint64_t attackerGuid = 0; + uint64_t victimGuid = 0; + bool isValid() const { return attackerGuid != 0 && victimGuid != 0; } +}; + +class AttackStartParser { +public: + static bool parse(network::Packet& packet, AttackStartData& data); +}; + +/** SMSG_ATTACKSTOP data */ +struct AttackStopData { + uint64_t attackerGuid = 0; + uint64_t victimGuid = 0; + uint32_t unknown = 0; + bool isValid() const { return true; } +}; + +class AttackStopParser { +public: + static bool parse(network::Packet& packet, AttackStopData& data); +}; + +/** Sub-damage entry for melee hits */ +struct SubDamage { + uint32_t schoolMask = 0; + float damage = 0.0f; + uint32_t intDamage = 0; + uint32_t absorbed = 0; + uint32_t resisted = 0; +}; + +/** SMSG_ATTACKERSTATEUPDATE data */ +struct AttackerStateUpdateData { + uint32_t hitInfo = 0; + uint64_t attackerGuid = 0; + uint64_t targetGuid = 0; + int32_t totalDamage = 0; + uint8_t subDamageCount = 0; + std::vector subDamages; + uint32_t victimState = 0; // 0=hit, 1=dodge, 2=parry, 3=interrupt, 4=block, etc. + int32_t overkill = -1; + uint32_t blocked = 0; + + bool isValid() const { return attackerGuid != 0; } + bool isCrit() const { return (hitInfo & 0x200) != 0; } + bool isMiss() const { return (hitInfo & 0x10) != 0; } +}; + +class AttackerStateUpdateParser { +public: + static bool parse(network::Packet& packet, AttackerStateUpdateData& data); +}; + +/** SMSG_SPELLNONMELEEDAMAGELOG data (simplified) */ +struct SpellDamageLogData { + uint64_t targetGuid = 0; + uint64_t attackerGuid = 0; + uint32_t spellId = 0; + uint32_t damage = 0; + uint32_t overkill = 0; + uint8_t schoolMask = 0; + uint32_t absorbed = 0; + uint32_t resisted = 0; + bool isCrit = false; + + bool isValid() const { return spellId != 0; } +}; + +class SpellDamageLogParser { +public: + static bool parse(network::Packet& packet, SpellDamageLogData& data); +}; + +/** SMSG_SPELLHEALLOG data (simplified) */ +struct SpellHealLogData { + uint64_t targetGuid = 0; + uint64_t casterGuid = 0; + uint32_t spellId = 0; + uint32_t heal = 0; + uint32_t overheal = 0; + uint32_t absorbed = 0; + bool isCrit = false; + + bool isValid() const { return spellId != 0; } +}; + +class SpellHealLogParser { +public: + static bool parse(network::Packet& packet, SpellHealLogData& data); +}; + +// ============================================================ +// Phase 3: Spells, Action Bar, Auras +// ============================================================ + +/** SMSG_INITIAL_SPELLS data */ +struct InitialSpellsData { + uint8_t talentSpec = 0; + std::vector spellIds; + std::vector cooldowns; + + bool isValid() const { return true; } +}; + +class InitialSpellsParser { +public: + static bool parse(network::Packet& packet, InitialSpellsData& data); +}; + +/** CMSG_CAST_SPELL packet builder */ +class CastSpellPacket { +public: + static network::Packet build(uint32_t spellId, uint64_t targetGuid, uint8_t castCount); +}; + +/** CMSG_CANCEL_CAST packet builder */ +class CancelCastPacket { +public: + static network::Packet build(uint32_t spellId); +}; + +/** CMSG_CANCEL_AURA packet builder */ +class CancelAuraPacket { +public: + static network::Packet build(uint32_t spellId); +}; + +/** SMSG_CAST_FAILED data */ +struct CastFailedData { + uint8_t castCount = 0; + uint32_t spellId = 0; + uint8_t result = 0; + + bool isValid() const { return spellId != 0; } +}; + +class CastFailedParser { +public: + static bool parse(network::Packet& packet, CastFailedData& data); +}; + +/** SMSG_SPELL_START data (simplified) */ +struct SpellStartData { + uint64_t casterGuid = 0; + uint64_t casterUnit = 0; + uint8_t castCount = 0; + uint32_t spellId = 0; + uint32_t castFlags = 0; + uint32_t castTime = 0; + uint64_t targetGuid = 0; + + bool isValid() const { return spellId != 0; } +}; + +class SpellStartParser { +public: + static bool parse(network::Packet& packet, SpellStartData& data); +}; + +/** SMSG_SPELL_GO data (simplified) */ +struct SpellGoData { + uint64_t casterGuid = 0; + uint64_t casterUnit = 0; + uint8_t castCount = 0; + uint32_t spellId = 0; + uint32_t castFlags = 0; + uint8_t hitCount = 0; + std::vector hitTargets; + uint8_t missCount = 0; + + bool isValid() const { return spellId != 0; } +}; + +class SpellGoParser { +public: + static bool parse(network::Packet& packet, SpellGoData& data); +}; + +/** SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL data */ +struct AuraUpdateData { + uint64_t guid = 0; + std::vector> updates; // slot index + aura data + + bool isValid() const { return true; } +}; + +class AuraUpdateParser { +public: + static bool parse(network::Packet& packet, AuraUpdateData& data, bool isAll); +}; + +/** SMSG_SPELL_COOLDOWN data */ +struct SpellCooldownData { + uint64_t guid = 0; + uint8_t flags = 0; + std::vector> cooldowns; // spellId, cooldownMs + + bool isValid() const { return true; } +}; + +class SpellCooldownParser { +public: + static bool parse(network::Packet& packet, SpellCooldownData& data); +}; + +// ============================================================ +// Phase 4: Group/Party System +// ============================================================ + +/** CMSG_GROUP_INVITE packet builder */ +class GroupInvitePacket { +public: + static network::Packet build(const std::string& playerName); +}; + +/** SMSG_GROUP_INVITE data */ +struct GroupInviteResponseData { + uint8_t canAccept = 0; + std::string inviterName; + + bool isValid() const { return !inviterName.empty(); } +}; + +class GroupInviteResponseParser { +public: + static bool parse(network::Packet& packet, GroupInviteResponseData& data); +}; + +/** CMSG_GROUP_ACCEPT packet builder */ +class GroupAcceptPacket { +public: + static network::Packet build(); +}; + +/** CMSG_GROUP_DECLINE packet builder */ +class GroupDeclinePacket { +public: + static network::Packet build(); +}; + +/** CMSG_GROUP_DISBAND (leave party) packet builder */ +class GroupDisbandPacket { +public: + static network::Packet build(); +}; + +/** SMSG_GROUP_LIST parser */ +class GroupListParser { +public: + static bool parse(network::Packet& packet, GroupListData& data); +}; + +/** SMSG_PARTY_COMMAND_RESULT data */ +struct PartyCommandResultData { + PartyCommand command; + std::string name; + PartyResult result; + + bool isValid() const { return true; } +}; + +class PartyCommandResultParser { +public: + static bool parse(network::Packet& packet, PartyCommandResultData& data); +}; + +/** SMSG_GROUP_DECLINE data */ +struct GroupDeclineData { + std::string playerName; + bool isValid() const { return !playerName.empty(); } +}; + +class GroupDeclineResponseParser { +public: + static bool parse(network::Packet& packet, GroupDeclineData& data); +}; + +// ============================================================ +// Phase 5: Loot System +// ============================================================ + +/** Loot item entry */ +struct LootItem { + uint8_t slotIndex = 0; + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t displayInfoId = 0; + uint32_t randomSuffix = 0; + uint32_t randomPropertyId = 0; + uint8_t lootSlotType = 0; +}; + +/** SMSG_LOOT_RESPONSE data */ +struct LootResponseData { + uint64_t lootGuid = 0; + uint8_t lootType = 0; + uint32_t gold = 0; // In copper + std::vector items; + + bool isValid() const { return true; } + uint32_t getGold() const { return gold / 10000; } + uint32_t getSilver() const { return (gold / 100) % 100; } + uint32_t getCopper() const { return gold % 100; } +}; + +/** CMSG_LOOT packet builder */ +class LootPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + +/** CMSG_AUTOSTORE_LOOT_ITEM packet builder */ +class AutostoreLootItemPacket { +public: + static network::Packet build(uint8_t slotIndex); +}; + +/** CMSG_LOOT_RELEASE packet builder */ +class LootReleasePacket { +public: + static network::Packet build(uint64_t lootGuid); +}; + +/** SMSG_LOOT_RESPONSE parser */ +class LootResponseParser { +public: + static bool parse(network::Packet& packet, LootResponseData& data); +}; + +// ============================================================ +// Phase 5: NPC Gossip +// ============================================================ + +/** Gossip menu option */ +struct GossipOption { + uint32_t id = 0; + uint8_t icon = 0; // 0=chat, 1=vendor, 2=taxi, 3=trainer, etc. + bool isCoded = false; + uint32_t boxMoney = 0; + std::string text; + std::string boxText; +}; + +/** Gossip quest item */ +struct GossipQuestItem { + uint32_t questId = 0; + uint32_t questIcon = 0; + int32_t questLevel = 0; + uint32_t questFlags = 0; + uint8_t isRepeatable = 0; + std::string title; +}; + +/** SMSG_GOSSIP_MESSAGE data */ +struct GossipMessageData { + uint64_t npcGuid = 0; + uint32_t menuId = 0; + uint32_t titleTextId = 0; + std::vector options; + std::vector quests; + + bool isValid() const { return true; } +}; + +/** CMSG_GOSSIP_HELLO packet builder */ +class GossipHelloPacket { +public: + static network::Packet build(uint64_t npcGuid); +}; + +/** CMSG_GOSSIP_SELECT_OPTION packet builder */ +class GossipSelectOptionPacket { +public: + static network::Packet build(uint64_t npcGuid, uint32_t optionId, const std::string& code = ""); +}; + +/** SMSG_GOSSIP_MESSAGE parser */ +class GossipMessageParser { +public: + static bool parse(network::Packet& packet, GossipMessageData& data); +}; + +// ============================================================ +// Phase 5: Vendor +// ============================================================ + +/** Vendor item entry */ +struct VendorItem { + uint32_t slot = 0; + uint32_t itemId = 0; + uint32_t displayInfoId = 0; + int32_t maxCount = -1; // -1 = unlimited + uint32_t buyPrice = 0; // In copper + uint32_t durability = 0; + uint32_t stackCount = 0; + uint32_t extendedCost = 0; +}; + +/** SMSG_LIST_INVENTORY data */ +struct ListInventoryData { + uint64_t vendorGuid = 0; + std::vector items; + + bool isValid() const { return true; } +}; + +/** CMSG_LIST_INVENTORY packet builder */ +class ListInventoryPacket { +public: + static network::Packet build(uint64_t npcGuid); +}; + +/** CMSG_BUY_ITEM packet builder */ +class BuyItemPacket { +public: + static network::Packet build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); +}; + +/** CMSG_SELL_ITEM packet builder */ +class SellItemPacket { +public: + static network::Packet build(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count); +}; + +/** SMSG_LIST_INVENTORY parser */ +class ListInventoryParser { +public: + static bool parse(network::Packet& packet, ListInventoryData& data); +}; + } // namespace game } // namespace wowee diff --git a/include/rendering/performance_hud.hpp b/include/rendering/performance_hud.hpp index 20e109df..fa4f1d67 100644 --- a/include/rendering/performance_hud.hpp +++ b/include/rendering/performance_hud.hpp @@ -72,7 +72,7 @@ private: */ void calculateFPS(); - bool enabled = true; // Enabled by default, press F1 to toggle + bool enabled = false; // Disabled by default, press F1 to toggle Position position = Position::TOP_LEFT; // Section visibility diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f5e4893e..bd5179db 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -35,9 +35,9 @@ private: int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, etc. // UI state - bool showEntityWindow = true; + bool showEntityWindow = false; bool showChatWindow = true; - bool showPlayerInfo = true; + bool showPlayerInfo = false; bool refocusChatInput = false; /** @@ -95,6 +95,17 @@ private: */ void updateCharacterTextures(game::Inventory& inventory); + // ---- New UI renders ---- + void renderActionBar(game::GameHandler& gameHandler); + void renderCastBar(game::GameHandler& gameHandler); + void renderCombatText(game::GameHandler& gameHandler); + void renderPartyFrames(game::GameHandler& gameHandler); + void renderGroupInvitePopup(game::GameHandler& gameHandler); + void renderBuffBar(game::GameHandler& gameHandler); + void renderLootWindow(game::GameHandler& gameHandler); + void renderGossipWindow(game::GameHandler& gameHandler); + void renderVendorWindow(game::GameHandler& gameHandler); + /** * Inventory screen */ diff --git a/src/core/application.cpp b/src/core/application.cpp index 3c0f41f3..66c56d33 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -189,6 +189,7 @@ void Application::run() { if (event.window.event == SDL_WINDOWEVENT_RESIZED) { int newWidth = event.window.data1; int newHeight = event.window.data2; + window->setSize(newWidth, newHeight); glViewport(0, 0, newWidth, newHeight); if (renderer && renderer->getCamera()) { renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); @@ -629,8 +630,8 @@ void Application::update(float deltaTime) { break; } - // Update renderer (camera, etc.) - if (renderer) { + // Update renderer (camera, etc.) only when in-game + if (renderer && state == AppState::IN_GAME) { renderer->update(deltaTime); } @@ -647,25 +648,13 @@ void Application::render() { renderer->beginFrame(); - // Always render atmospheric background (sky, stars, clouds, etc.) - // This provides a nice animated background for login/UI screens - // Terrain/characters only render if loaded (in-game) - switch (state) { - case AppState::IN_GAME: - // Render full world with terrain and entities - if (world) { - renderer->renderWorld(world.get()); - } else { - // Fallback: render just atmosphere if world not loaded - renderer->renderWorld(nullptr); - } - break; - - default: - // Login/UI screens: render atmospheric background only - // (terrain/water/characters won't render as they're not loaded) + // Only render 3D world when in-game (after server connect or single-player) + if (state == AppState::IN_GAME) { + if (world) { + renderer->renderWorld(world.get()); + } else { renderer->renderWorld(nullptr); - break; + } } // Render performance HUD (within ImGui frame, before UI ends the frame) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7696556c..d184028c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -106,6 +106,37 @@ void GameHandler::update(float deltaTime) { sendPing(); timeSinceLastPing = 0.0f; } + + // Update cast timer (Phase 3) + if (casting && castTimeRemaining > 0.0f) { + castTimeRemaining -= deltaTime; + if (castTimeRemaining <= 0.0f) { + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + } + } + + // Update spell cooldowns (Phase 3) + for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) { + it->second -= deltaTime; + if (it->second <= 0.0f) { + it = spellCooldowns.erase(it); + } else { + ++it; + } + } + + // Update action bar cooldowns + for (auto& slot : actionBar) { + if (slot.cooldownRemaining > 0.0f) { + slot.cooldownRemaining -= deltaTime; + if (slot.cooldownRemaining < 0.0f) slot.cooldownRemaining = 0.0f; + } + } + + // Update combat text (Phase 2) + updateCombatText(deltaTime); } } @@ -192,6 +223,127 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + // ---- Phase 1: Foundation ---- + case Opcode::SMSG_NAME_QUERY_RESPONSE: + handleNameQueryResponse(packet); + break; + + case Opcode::SMSG_CREATURE_QUERY_RESPONSE: + handleCreatureQueryResponse(packet); + break; + + // ---- Phase 2: Combat ---- + case Opcode::SMSG_ATTACKSTART: + handleAttackStart(packet); + break; + case Opcode::SMSG_ATTACKSTOP: + handleAttackStop(packet); + break; + case Opcode::SMSG_ATTACKERSTATEUPDATE: + handleAttackerStateUpdate(packet); + break; + case Opcode::SMSG_SPELLNONMELEEDAMAGELOG: + handleSpellDamageLog(packet); + break; + case Opcode::SMSG_SPELLHEALLOG: + handleSpellHealLog(packet); + break; + + // ---- Phase 3: Spells ---- + case Opcode::SMSG_INITIAL_SPELLS: + handleInitialSpells(packet); + break; + case Opcode::SMSG_CAST_FAILED: + handleCastFailed(packet); + break; + case Opcode::SMSG_SPELL_START: + handleSpellStart(packet); + break; + case Opcode::SMSG_SPELL_GO: + handleSpellGo(packet); + break; + case Opcode::SMSG_SPELL_FAILURE: + // Spell failed mid-cast + casting = false; + currentCastSpellId = 0; + break; + case Opcode::SMSG_SPELL_COOLDOWN: + handleSpellCooldown(packet); + break; + case Opcode::SMSG_COOLDOWN_EVENT: + handleCooldownEvent(packet); + break; + case Opcode::SMSG_AURA_UPDATE: + handleAuraUpdate(packet, false); + break; + case Opcode::SMSG_AURA_UPDATE_ALL: + handleAuraUpdate(packet, true); + break; + case Opcode::SMSG_LEARNED_SPELL: + handleLearnedSpell(packet); + break; + case Opcode::SMSG_REMOVED_SPELL: + handleRemovedSpell(packet); + break; + + // ---- Phase 4: Group ---- + case Opcode::SMSG_GROUP_INVITE: + handleGroupInvite(packet); + break; + case Opcode::SMSG_GROUP_DECLINE: + handleGroupDecline(packet); + break; + case Opcode::SMSG_GROUP_LIST: + handleGroupList(packet); + break; + case Opcode::SMSG_GROUP_UNINVITE: + handleGroupUninvite(packet); + break; + case Opcode::SMSG_PARTY_COMMAND_RESULT: + handlePartyCommandResult(packet); + break; + + // ---- Phase 5: Loot/Gossip/Vendor ---- + case Opcode::SMSG_LOOT_RESPONSE: + handleLootResponse(packet); + break; + case Opcode::SMSG_LOOT_RELEASE_RESPONSE: + handleLootReleaseResponse(packet); + break; + case Opcode::SMSG_LOOT_REMOVED: + handleLootRemoved(packet); + break; + case Opcode::SMSG_GOSSIP_MESSAGE: + handleGossipMessage(packet); + break; + case Opcode::SMSG_GOSSIP_COMPLETE: + handleGossipComplete(packet); + break; + case Opcode::SMSG_LIST_INVENTORY: + handleListInventory(packet); + break; + + // Silently ignore common packets we don't handle yet + case Opcode::SMSG_FEATURE_SYSTEM_STATUS: + case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: + case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: + case Opcode::SMSG_SPELL_DELAYED: + case Opcode::SMSG_UPDATE_AURA_DURATION: + case Opcode::SMSG_PERIODICAURALOG: + case Opcode::SMSG_SPELLENERGIZELOG: + case Opcode::SMSG_ENVIRONMENTALDAMAGELOG: + case Opcode::SMSG_LOOT_MONEY_NOTIFY: + case Opcode::SMSG_LOOT_CLEAR_MONEY: + case Opcode::SMSG_NPC_TEXT_UPDATE: + case Opcode::SMSG_SELL_ITEM: + case Opcode::SMSG_BUY_FAILED: + case Opcode::SMSG_INVENTORY_CHANGE_FAILURE: + case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: + case Opcode::MSG_RAID_TARGET_UPDATE: + case Opcode::SMSG_GROUP_SET_LEADER: + LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); + break; + default: LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); break; @@ -357,6 +509,9 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { } } + // Store player GUID + playerGuid = characterGuid; + // Build CMSG_PLAYER_LOGIN packet auto packet = PlayerLoginPacket::build(characterGuid); @@ -400,6 +555,13 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementInfo.flags = 0; movementInfo.flags2 = 0; movementInfo.time = 0; + + // Send CMSG_SET_ACTIVE_MOVER (required by some servers) + if (playerGuid != 0 && socket) { + auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); + socket->send(activeMoverPacket); + LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); + } } void GameHandler::handleAccountDataTimes(network::Packet& packet) { @@ -603,6 +765,35 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Add to manager entityManager.addEntity(block.guid, entity); + + // Auto-query names (Phase 1) + if (block.objectType == ObjectType::PLAYER) { + queryPlayerName(block.guid); + } else if (block.objectType == ObjectType::UNIT) { + // Extract creature entry from fields (UNIT_FIELD_ENTRY = index 54 in 3.3.5a, + // but the OBJECT_FIELD_ENTRY is at index 3) + auto it = block.fields.find(3); // OBJECT_FIELD_ENTRY + if (it != block.fields.end() && it->second != 0) { + auto unit = std::static_pointer_cast(entity); + unit->setEntry(it->second); + queryCreatureInfo(it->second, block.guid); + } + } + + // Extract health/mana/power from fields (Phase 2) + if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + auto hpIt = block.fields.find(24); // UNIT_FIELD_HEALTH + if (hpIt != block.fields.end()) unit->setHealth(hpIt->second); + auto maxHpIt = block.fields.find(32); // UNIT_FIELD_MAXHEALTH + if (maxHpIt != block.fields.end()) unit->setMaxHealth(maxHpIt->second); + auto powerIt = block.fields.find(25); // UNIT_FIELD_POWER1 + if (powerIt != block.fields.end()) unit->setPower(powerIt->second); + auto maxPowerIt = block.fields.find(33); // UNIT_FIELD_MAXPOWER1 + if (maxPowerIt != block.fields.end()) unit->setMaxPower(maxPowerIt->second); + auto levelIt = block.fields.find(54); // UNIT_FIELD_LEVEL + if (levelIt != block.fields.end()) unit->setLevel(levelIt->second); + } break; } @@ -613,6 +804,22 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { for (const auto& field : block.fields) { entity->setField(field.first, field.second); } + + // Update cached health/mana/power values (Phase 2) + if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + auto hpIt = block.fields.find(24); + if (hpIt != block.fields.end()) unit->setHealth(hpIt->second); + auto maxHpIt = block.fields.find(32); + if (maxHpIt != block.fields.end()) unit->setMaxHealth(maxHpIt->second); + auto powerIt = block.fields.find(25); + if (powerIt != block.fields.end()) unit->setPower(powerIt->second); + auto maxPowerIt = block.fields.find(33); + if (maxPowerIt != block.fields.end()) unit->setMaxPower(maxPowerIt->second); + auto levelIt = block.fields.find(54); + if (levelIt != block.fields.end()) unit->setLevel(levelIt->second); + } + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); } else { LOG_WARNING("VALUES update for unknown entity: 0x", std::hex, block.guid, std::dec); @@ -737,6 +944,13 @@ void GameHandler::handleMessageChat(network::Packet& packet) { void GameHandler::setTarget(uint64_t guid) { if (guid == targetGuid) return; targetGuid = guid; + + // Inform server of target selection (Phase 1) + if (state == WorldState::IN_WORLD && socket) { + auto packet = SetSelectionPacket::build(guid); + socket->send(packet); + } + if (guid != 0) { LOG_INFO("Target set: 0x", std::hex, guid, std::dec); } @@ -815,6 +1029,539 @@ std::vector GameHandler::getChatHistory(size_t maxMessages) con ); } +// ============================================================ +// Phase 1: Name Queries +// ============================================================ + +void GameHandler::queryPlayerName(uint64_t guid) { + if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return; + if (state != WorldState::IN_WORLD || !socket) return; + + pendingNameQueries.insert(guid); + auto packet = NameQueryPacket::build(guid); + socket->send(packet); +} + +void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) { + if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; + if (state != WorldState::IN_WORLD || !socket) return; + + pendingCreatureQueries.insert(entry); + auto packet = CreatureQueryPacket::build(entry, guid); + socket->send(packet); +} + +std::string GameHandler::getCachedPlayerName(uint64_t guid) const { + auto it = playerNameCache.find(guid); + return (it != playerNameCache.end()) ? it->second : ""; +} + +std::string GameHandler::getCachedCreatureName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.name : ""; +} + +void GameHandler::handleNameQueryResponse(network::Packet& packet) { + NameQueryResponseData data; + if (!NameQueryResponseParser::parse(packet, data)) return; + + pendingNameQueries.erase(data.guid); + + if (data.isValid()) { + playerNameCache[data.guid] = data.name; + // Update entity name + auto entity = entityManager.getEntity(data.guid); + if (entity && entity->getType() == ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + player->setName(data.name); + } + } +} + +void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { + CreatureQueryResponseData data; + if (!CreatureQueryResponseParser::parse(packet, data)) return; + + pendingCreatureQueries.erase(data.entry); + + if (data.isValid()) { + creatureInfoCache[data.entry] = data; + // Update all unit entities with this entry + for (auto& [guid, entity] : entityManager.getEntities()) { + if (entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (unit->getEntry() == data.entry) { + unit->setName(data.name); + } + } + } + } +} + +// ============================================================ +// Phase 2: Combat +// ============================================================ + +void GameHandler::startAutoAttack(uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + autoAttacking = true; + autoAttackTarget = targetGuid; + auto packet = AttackSwingPacket::build(targetGuid); + socket->send(packet); + LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec); +} + +void GameHandler::stopAutoAttack() { + if (!autoAttacking) return; + autoAttacking = false; + autoAttackTarget = 0; + if (state == WorldState::IN_WORLD && socket) { + auto packet = AttackStopPacket::build(); + socket->send(packet); + } + LOG_INFO("Stopping auto-attack"); +} + +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) { + CombatTextEntry entry; + entry.type = type; + entry.amount = amount; + entry.spellId = spellId; + entry.age = 0.0f; + entry.isPlayerSource = isPlayerSource; + combatText.push_back(entry); +} + +void GameHandler::updateCombatText(float deltaTime) { + for (auto& entry : combatText) { + entry.age += deltaTime; + } + combatText.erase( + std::remove_if(combatText.begin(), combatText.end(), + [](const CombatTextEntry& e) { return e.isExpired(); }), + combatText.end()); +} + +void GameHandler::handleAttackStart(network::Packet& packet) { + AttackStartData data; + if (!AttackStartParser::parse(packet, data)) return; + + if (data.attackerGuid == playerGuid) { + autoAttacking = true; + autoAttackTarget = data.victimGuid; + } +} + +void GameHandler::handleAttackStop(network::Packet& packet) { + AttackStopData data; + if (!AttackStopParser::parse(packet, data)) return; + + if (data.attackerGuid == playerGuid) { + autoAttacking = false; + autoAttackTarget = 0; + } +} + +void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { + AttackerStateUpdateData data; + if (!AttackerStateUpdateParser::parse(packet, data)) return; + + bool isPlayerAttacker = (data.attackerGuid == playerGuid); + bool isPlayerTarget = (data.targetGuid == playerGuid); + + if (data.isMiss()) { + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + } else if (data.victimState == 1) { + addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker); + } else if (data.victimState == 2) { + addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); + } else { + auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; + addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + } + + (void)isPlayerTarget; // Used for future incoming damage display +} + +void GameHandler::handleSpellDamageLog(network::Packet& packet) { + SpellDamageLogData data; + if (!SpellDamageLogParser::parse(packet, data)) return; + + bool isPlayerSource = (data.attackerGuid == playerGuid); + auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); +} + +void GameHandler::handleSpellHealLog(network::Packet& packet) { + SpellHealLogData data; + if (!SpellHealLogParser::parse(packet, data)) return; + + bool isPlayerSource = (data.casterGuid == playerGuid); + auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; + addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); +} + +// ============================================================ +// Phase 3: Spells +// ============================================================ + +void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + if (casting) return; // Already casting + + uint64_t target = targetGuid != 0 ? targetGuid : targetGuid; + auto packet = CastSpellPacket::build(spellId, target, ++castCount); + socket->send(packet); + LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); +} + +void GameHandler::cancelCast() { + if (!casting) return; + if (state == WorldState::IN_WORLD && socket) { + auto packet = CancelCastPacket::build(currentCastSpellId); + socket->send(packet); + } + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; +} + +void GameHandler::cancelAura(uint32_t spellId) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = CancelAuraPacket::build(spellId); + socket->send(packet); +} + +void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { + if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; + actionBar[slot].type = type; + actionBar[slot].id = id; +} + +float GameHandler::getSpellCooldown(uint32_t spellId) const { + auto it = spellCooldowns.find(spellId); + return (it != spellCooldowns.end()) ? it->second : 0.0f; +} + +void GameHandler::handleInitialSpells(network::Packet& packet) { + InitialSpellsData data; + if (!InitialSpellsParser::parse(packet, data)) return; + + knownSpells = data.spellIds; + + // Set initial cooldowns + for (const auto& cd : data.cooldowns) { + if (cd.cooldownMs > 0) { + spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f; + } + } + + // Auto-populate action bar with first 12 spells + for (int i = 0; i < ACTION_BAR_SLOTS && i < static_cast(knownSpells.size()); ++i) { + actionBar[i].type = ActionBarSlot::SPELL; + actionBar[i].id = knownSpells[i]; + } + + LOG_INFO("Learned ", knownSpells.size(), " spells"); +} + +void GameHandler::handleCastFailed(network::Packet& packet) { + CastFailedData data; + if (!CastFailedParser::parse(packet, data)) return; + + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + + // Add system message about failed cast + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")"; + addLocalChatMessage(msg); +} + +void GameHandler::handleSpellStart(network::Packet& packet) { + SpellStartData data; + if (!SpellStartParser::parse(packet, data)) return; + + // If this is the player's own cast, start cast bar + if (data.casterUnit == playerGuid && data.castTime > 0) { + casting = true; + currentCastSpellId = data.spellId; + castTimeTotal = data.castTime / 1000.0f; + castTimeRemaining = castTimeTotal; + } +} + +void GameHandler::handleSpellGo(network::Packet& packet) { + SpellGoData data; + if (!SpellGoParser::parse(packet, data)) return; + + // Cast completed + if (data.casterUnit == playerGuid) { + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + } +} + +void GameHandler::handleSpellCooldown(network::Packet& packet) { + SpellCooldownData data; + if (!SpellCooldownParser::parse(packet, data)) return; + + for (const auto& [spellId, cooldownMs] : data.cooldowns) { + float seconds = cooldownMs / 1000.0f; + spellCooldowns[spellId] = seconds; + // Update action bar cooldowns + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownTotal = seconds; + slot.cooldownRemaining = seconds; + } + } + } +} + +void GameHandler::handleCooldownEvent(network::Packet& packet) { + uint32_t spellId = packet.readUInt32(); + // Cooldown finished + spellCooldowns.erase(spellId); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownRemaining = 0.0f; + } + } +} + +void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { + AuraUpdateData data; + if (!AuraUpdateParser::parse(packet, data, isAll)) return; + + // Determine which aura list to update + std::vector* auraList = nullptr; + if (data.guid == playerGuid) { + auraList = &playerAuras; + } else if (data.guid == targetGuid) { + auraList = &targetAuras; + } + + if (auraList) { + for (const auto& [slot, aura] : data.updates) { + // Ensure vector is large enough + while (auraList->size() <= slot) { + auraList->push_back(AuraSlot{}); + } + (*auraList)[slot] = aura; + } + } +} + +void GameHandler::handleLearnedSpell(network::Packet& packet) { + uint32_t spellId = packet.readUInt32(); + knownSpells.push_back(spellId); + LOG_INFO("Learned spell: ", spellId); +} + +void GameHandler::handleRemovedSpell(network::Packet& packet) { + uint32_t spellId = packet.readUInt32(); + knownSpells.erase( + std::remove(knownSpells.begin(), knownSpells.end(), spellId), + knownSpells.end()); + LOG_INFO("Removed spell: ", spellId); +} + +// ============================================================ +// Phase 4: Group/Party +// ============================================================ + +void GameHandler::inviteToGroup(const std::string& playerName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GroupInvitePacket::build(playerName); + socket->send(packet); + LOG_INFO("Inviting ", playerName, " to group"); +} + +void GameHandler::acceptGroupInvite() { + if (state != WorldState::IN_WORLD || !socket) return; + pendingGroupInvite = false; + auto packet = GroupAcceptPacket::build(); + socket->send(packet); + LOG_INFO("Accepted group invite"); +} + +void GameHandler::declineGroupInvite() { + if (state != WorldState::IN_WORLD || !socket) return; + pendingGroupInvite = false; + auto packet = GroupDeclinePacket::build(); + socket->send(packet); + LOG_INFO("Declined group invite"); +} + +void GameHandler::leaveGroup() { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GroupDisbandPacket::build(); + socket->send(packet); + partyData = GroupListData{}; + LOG_INFO("Left group"); +} + +void GameHandler::handleGroupInvite(network::Packet& packet) { + GroupInviteResponseData data; + if (!GroupInviteResponseParser::parse(packet, data)) return; + + pendingGroupInvite = true; + pendingInviterName = data.inviterName; + LOG_INFO("Group invite from: ", data.inviterName); +} + +void GameHandler::handleGroupDecline(network::Packet& packet) { + GroupDeclineData data; + if (!GroupDeclineResponseParser::parse(packet, data)) return; + + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = data.playerName + " has declined your group invitation."; + addLocalChatMessage(msg); +} + +void GameHandler::handleGroupList(network::Packet& packet) { + if (!GroupListParser::parse(packet, partyData)) return; + + if (partyData.isEmpty()) { + LOG_INFO("No longer in a group"); + } else { + LOG_INFO("In group with ", partyData.memberCount, " members"); + } +} + +void GameHandler::handleGroupUninvite(network::Packet& packet) { + (void)packet; + partyData = GroupListData{}; + LOG_INFO("Removed from group"); + + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = "You have been removed from the group."; + addLocalChatMessage(msg); +} + +void GameHandler::handlePartyCommandResult(network::Packet& packet) { + PartyCommandResultData data; + if (!PartyCommandResultParser::parse(packet, data)) return; + + if (data.result != PartyResult::OK) { + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = "Party command failed (error " + std::to_string(static_cast(data.result)) + ")"; + if (!data.name.empty()) msg.message += " for " + data.name; + addLocalChatMessage(msg); + } +} + +// ============================================================ +// Phase 5: Loot, Gossip, Vendor +// ============================================================ + +void GameHandler::lootTarget(uint64_t guid) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = LootPacket::build(guid); + socket->send(packet); +} + +void GameHandler::lootItem(uint8_t slotIndex) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = AutostoreLootItemPacket::build(slotIndex); + socket->send(packet); +} + +void GameHandler::closeLoot() { + if (!lootWindowOpen) return; + lootWindowOpen = false; + if (state == WorldState::IN_WORLD && socket) { + auto packet = LootReleasePacket::build(currentLoot.lootGuid); + socket->send(packet); + } + currentLoot = LootResponseData{}; +} + +void GameHandler::interactWithNpc(uint64_t guid) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GossipHelloPacket::build(guid); + socket->send(packet); +} + +void GameHandler::selectGossipOption(uint32_t optionId) { + if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return; + auto packet = GossipSelectOptionPacket::build(currentGossip.npcGuid, optionId); + socket->send(packet); +} + +void GameHandler::closeGossip() { + gossipWindowOpen = false; + currentGossip = GossipMessageData{}; +} + +void GameHandler::openVendor(uint64_t npcGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = ListInventoryPacket::build(npcGuid); + socket->send(packet); +} + +void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count); + socket->send(packet); +} + +void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = SellItemPacket::build(vendorGuid, itemGuid, count); + socket->send(packet); +} + +void GameHandler::handleLootResponse(network::Packet& packet) { + if (!LootResponseParser::parse(packet, currentLoot)) return; + lootWindowOpen = true; +} + +void GameHandler::handleLootReleaseResponse(network::Packet& packet) { + (void)packet; + lootWindowOpen = false; + currentLoot = LootResponseData{}; +} + +void GameHandler::handleLootRemoved(network::Packet& packet) { + uint8_t slotIndex = packet.readUInt8(); + for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { + if (it->slotIndex == slotIndex) { + currentLoot.items.erase(it); + break; + } + } +} + +void GameHandler::handleGossipMessage(network::Packet& packet) { + if (!GossipMessageParser::parse(packet, currentGossip)) return; + gossipWindowOpen = true; + vendorWindowOpen = false; // Close vendor if gossip opens +} + +void GameHandler::handleGossipComplete(network::Packet& packet) { + (void)packet; + gossipWindowOpen = false; + currentGossip = GossipMessageData{}; +} + +void GameHandler::handleListInventory(network::Packet& packet) { + if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + vendorWindowOpen = true; + gossipWindowOpen = false; // Close gossip if vendor opens +} + uint32_t GameHandler::generateClientSeed() { // Generate cryptographically random seed std::random_device rd; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 88ed1c5d..638f84a5 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -861,5 +861,642 @@ const char* getChatTypeString(ChatType type) { } } +// ============================================================ +// Phase 1: Foundation — Targeting, Name Queries +// ============================================================ + +network::Packet SetSelectionPacket::build(uint64_t targetGuid) { + network::Packet packet(static_cast(Opcode::CMSG_SET_SELECTION)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_SET_SELECTION: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet SetActiveMoverPacket::build(uint64_t guid) { + network::Packet packet(static_cast(Opcode::CMSG_SET_ACTIVE_MOVER)); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_SET_ACTIVE_MOVER: guid=0x", std::hex, guid, std::dec); + return packet; +} + +network::Packet NameQueryPacket::build(uint64_t playerGuid) { + network::Packet packet(static_cast(Opcode::CMSG_NAME_QUERY)); + packet.writeUInt64(playerGuid); + LOG_DEBUG("Built CMSG_NAME_QUERY: guid=0x", std::hex, playerGuid, std::dec); + return packet; +} + +bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseData& data) { + // 3.3.5a: packedGuid, uint8 found + // If found==0: CString name, CString realmName, uint8 race, uint8 gender, uint8 classId + data.guid = UpdateObjectParser::readPackedGuid(packet); + data.found = packet.readUInt8(); + + if (data.found != 0) { + LOG_DEBUG("Name query: player not found for GUID 0x", std::hex, data.guid, std::dec); + return true; // Valid response, just not found + } + + data.name = packet.readString(); + data.realmName = packet.readString(); + data.race = packet.readUInt8(); + data.gender = packet.readUInt8(); + data.classId = packet.readUInt8(); + + LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race, + " class=", (int)data.classId, ")"); + return true; +} + +network::Packet CreatureQueryPacket::build(uint32_t entry, uint64_t guid) { + network::Packet packet(static_cast(Opcode::CMSG_CREATURE_QUERY)); + packet.writeUInt32(entry); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_CREATURE_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec); + return packet; +} + +bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryResponseData& data) { + data.entry = packet.readUInt32(); + + // High bit set means creature not found + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + LOG_DEBUG("Creature query: entry ", data.entry, " not found"); + data.name = ""; + return true; + } + + // 4 name strings (only first is usually populated) + data.name = packet.readString(); + packet.readString(); // name2 + packet.readString(); // name3 + packet.readString(); // name4 + data.subName = packet.readString(); + data.iconName = packet.readString(); + data.typeFlags = packet.readUInt32(); + data.creatureType = packet.readUInt32(); + data.family = packet.readUInt32(); + data.rank = packet.readUInt32(); + + // Skip remaining fields (kill credits, display IDs, modifiers, quest items, etc.) + // We've got what we need for display purposes + + LOG_INFO("Creature query response: ", data.name, " (type=", data.creatureType, + " rank=", data.rank, ")"); + return true; +} + +// ============================================================ +// Phase 2: Combat Core +// ============================================================ + +network::Packet AttackSwingPacket::build(uint64_t targetGuid) { + network::Packet packet(static_cast(Opcode::CMSG_ATTACKSWING)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_ATTACKSWING: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet AttackStopPacket::build() { + network::Packet packet(static_cast(Opcode::CMSG_ATTACKSTOP)); + LOG_DEBUG("Built CMSG_ATTACKSTOP"); + return packet; +} + +bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) { + if (packet.getSize() < 16) return false; + data.attackerGuid = packet.readUInt64(); + data.victimGuid = packet.readUInt64(); + LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid, + " -> 0x", data.victimGuid, std::dec); + return true; +} + +bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + data.victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() < packet.getSize()) { + data.unknown = packet.readUInt32(); + } + LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); + return true; +} + +bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) { + data.hitInfo = packet.readUInt32(); + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + data.totalDamage = static_cast(packet.readUInt32()); + data.subDamageCount = packet.readUInt8(); + + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + SubDamage sub; + sub.schoolMask = packet.readUInt32(); + sub.damage = packet.readFloat(); + sub.intDamage = packet.readUInt32(); + sub.absorbed = packet.readUInt32(); + sub.resisted = packet.readUInt32(); + data.subDamages.push_back(sub); + } + + data.victimState = packet.readUInt32(); + data.overkill = static_cast(packet.readUInt32()); + + // Read blocked amount + if (packet.getReadPos() < packet.getSize()) { + data.blocked = packet.readUInt32(); + } + + LOG_INFO("Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) { + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.overkill = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); + + // Skip remaining fields + uint8_t periodicLog = packet.readUInt8(); + (void)periodicLog; + packet.readUInt8(); // unused + packet.readUInt32(); // blocked + uint32_t flags = packet.readUInt32(); + (void)flags; + // Check crit flag + data.isCrit = (flags & 0x02) != 0; + + LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + +bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) { + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); + data.absorbed = packet.readUInt32(); + uint8_t critFlag = packet.readUInt8(); + data.isCrit = (critFlag != 0); + + LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================ +// Phase 3: Spells, Action Bar, Auras +// ============================================================ + +bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data) { + data.talentSpec = packet.readUInt8(); + uint16_t spellCount = packet.readUInt16(); + + data.spellIds.reserve(spellCount); + for (uint16_t i = 0; i < spellCount; ++i) { + uint32_t spellId = packet.readUInt32(); + packet.readUInt16(); // unknown (always 0) + if (spellId != 0) { + data.spellIds.push_back(spellId); + } + } + + uint16_t cooldownCount = packet.readUInt16(); + data.cooldowns.reserve(cooldownCount); + for (uint16_t i = 0; i < cooldownCount; ++i) { + SpellCooldownEntry entry; + entry.spellId = packet.readUInt32(); + entry.itemId = packet.readUInt16(); + entry.categoryId = packet.readUInt16(); + entry.cooldownMs = packet.readUInt32(); + entry.categoryCooldownMs = packet.readUInt32(); + data.cooldowns.push_back(entry); + } + + LOG_INFO("Initial spells: ", data.spellIds.size(), " spells, ", + data.cooldowns.size(), " cooldowns"); + return true; +} + +network::Packet CastSpellPacket::build(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) { + network::Packet packet(static_cast(Opcode::CMSG_CAST_SPELL)); + packet.writeUInt8(castCount); + packet.writeUInt32(spellId); + packet.writeUInt8(0x00); // castFlags = 0 for normal cast + + // SpellCastTargets + if (targetGuid != 0) { + packet.writeUInt32(0x02); // TARGET_FLAG_UNIT + + // Write packed GUID + uint8_t mask = 0; + uint8_t bytes[8]; + int byteCount = 0; + uint64_t g = targetGuid; + for (int i = 0; i < 8; ++i) { + uint8_t b = g & 0xFF; + if (b != 0) { + mask |= (1 << i); + bytes[byteCount++] = b; + } + g >>= 8; + } + packet.writeUInt8(mask); + for (int i = 0; i < byteCount; ++i) { + packet.writeUInt8(bytes[i]); + } + } else { + packet.writeUInt32(0x00); // TARGET_FLAG_SELF + } + + LOG_DEBUG("Built CMSG_CAST_SPELL: spell=", spellId, " target=0x", + std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet CancelCastPacket::build(uint32_t spellId) { + network::Packet packet(static_cast(Opcode::CMSG_CANCEL_CAST)); + packet.writeUInt32(0); // sequence + packet.writeUInt32(spellId); + return packet; +} + +network::Packet CancelAuraPacket::build(uint32_t spellId) { + network::Packet packet(static_cast(Opcode::CMSG_CANCEL_AURA)); + packet.writeUInt32(spellId); + return packet; +} + +bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.result = packet.readUInt8(); + LOG_INFO("Cast failed: spell=", data.spellId, " result=", (int)data.result); + return true; +} + +bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + data.castTime = packet.readUInt32(); + + // Read target flags and target (simplified) + if (packet.getReadPos() < packet.getSize()) { + uint32_t targetFlags = packet.readUInt32(); + if (targetFlags & 0x02) { // TARGET_FLAG_UNIT + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + } + } + + LOG_INFO("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + return true; +} + +bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + // Timestamp in 3.3.5a + packet.readUInt32(); + + data.hitCount = packet.readUInt8(); + data.hitTargets.reserve(data.hitCount); + for (uint8_t i = 0; i < data.hitCount; ++i) { + data.hitTargets.push_back(packet.readUInt64()); + } + + data.missCount = packet.readUInt8(); + // Skip miss details for now + + LOG_INFO("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, + " misses=", (int)data.missCount); + return true; +} + +bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) { + data.guid = UpdateObjectParser::readPackedGuid(packet); + + while (packet.getReadPos() < packet.getSize()) { + uint8_t slot = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + + AuraSlot aura; + if (spellId != 0) { + aura.spellId = spellId; + aura.flags = packet.readUInt8(); + aura.level = packet.readUInt8(); + aura.charges = packet.readUInt8(); + + if (!(aura.flags & 0x08)) { // NOT_CASTER flag + aura.casterGuid = UpdateObjectParser::readPackedGuid(packet); + } + + if (aura.flags & 0x20) { // DURATION + aura.maxDurationMs = static_cast(packet.readUInt32()); + aura.durationMs = static_cast(packet.readUInt32()); + } + + if (aura.flags & 0x40) { // EFFECT_AMOUNTS - skip + // 3 effect amounts + for (int i = 0; i < 3; ++i) { + if (packet.getReadPos() < packet.getSize()) { + packet.readUInt32(); + } + } + } + } + + data.updates.push_back({slot, aura}); + + // For single update, only one entry + if (!isAll) break; + } + + LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec, + ": ", data.updates.size(), " slots"); + return true; +} + +bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) { + data.guid = packet.readUInt64(); + data.flags = packet.readUInt8(); + + while (packet.getReadPos() + 8 <= packet.getSize()) { + uint32_t spellId = packet.readUInt32(); + uint32_t cooldownMs = packet.readUInt32(); + data.cooldowns.push_back({spellId, cooldownMs}); + } + + LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries"); + return true; +} + +// ============================================================ +// Phase 4: Group/Party System +// ============================================================ + +network::Packet GroupInvitePacket::build(const std::string& playerName) { + network::Packet packet(static_cast(Opcode::CMSG_GROUP_INVITE)); + packet.writeString(playerName); + packet.writeUInt32(0); // unused + LOG_DEBUG("Built CMSG_GROUP_INVITE: ", playerName); + return packet; +} + +bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) { + data.canAccept = packet.readUInt8(); + data.inviterName = packet.readString(); + LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", (int)data.canAccept, ")"); + return true; +} + +network::Packet GroupAcceptPacket::build() { + network::Packet packet(static_cast(Opcode::CMSG_GROUP_ACCEPT)); + packet.writeUInt32(0); // unused in 3.3.5a + return packet; +} + +network::Packet GroupDeclinePacket::build() { + network::Packet packet(static_cast(Opcode::CMSG_GROUP_DECLINE)); + return packet; +} + +network::Packet GroupDisbandPacket::build() { + network::Packet packet(static_cast(Opcode::CMSG_GROUP_DISBAND)); + return packet; +} + +bool GroupListParser::parse(network::Packet& packet, GroupListData& data) { + data.groupType = packet.readUInt8(); + data.subGroup = packet.readUInt8(); + data.flags = packet.readUInt8(); + data.roles = packet.readUInt8(); + + // Skip LFG data if present + if (data.groupType & 0x04) { + packet.readUInt8(); // lfg state + packet.readUInt32(); // lfg entry + packet.readUInt8(); // lfg flags (3.3.5a may not have this) + } + + packet.readUInt64(); // group GUID + packet.readUInt32(); // counter + + data.memberCount = packet.readUInt32(); + data.members.reserve(data.memberCount); + + for (uint32_t i = 0; i < data.memberCount; ++i) { + GroupMember member; + member.name = packet.readString(); + member.guid = packet.readUInt64(); + member.isOnline = packet.readUInt8(); + member.subGroup = packet.readUInt8(); + member.flags = packet.readUInt8(); + member.roles = packet.readUInt8(); + data.members.push_back(member); + } + + data.leaderGuid = packet.readUInt64(); + + if (data.memberCount > 0 && packet.getReadPos() < packet.getSize()) { + data.lootMethod = packet.readUInt8(); + data.looterGuid = packet.readUInt64(); + data.lootThreshold = packet.readUInt8(); + data.difficultyId = packet.readUInt8(); + data.raidDifficultyId = packet.readUInt8(); + if (packet.getReadPos() < packet.getSize()) { + packet.readUInt8(); // unknown byte + } + } + + LOG_INFO("Group list: ", data.memberCount, " members, leader=0x", + std::hex, data.leaderGuid, std::dec); + return true; +} + +bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) { + data.command = static_cast(packet.readUInt32()); + data.name = packet.readString(); + data.result = static_cast(packet.readUInt32()); + LOG_INFO("Party command result: ", (int)data.result); + return true; +} + +bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) { + data.playerName = packet.readString(); + LOG_INFO("Group decline from: ", data.playerName); + return true; +} + +// ============================================================ +// Phase 5: Loot System +// ============================================================ + +network::Packet LootPacket::build(uint64_t targetGuid) { + network::Packet packet(static_cast(Opcode::CMSG_LOOT)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_LOOT: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) { + network::Packet packet(static_cast(Opcode::CMSG_AUTOSTORE_LOOT_ITEM)); + packet.writeUInt8(slotIndex); + return packet; +} + +network::Packet LootReleasePacket::build(uint64_t lootGuid) { + network::Packet packet(static_cast(Opcode::CMSG_LOOT_RELEASE)); + packet.writeUInt64(lootGuid); + return packet; +} + +bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) { + data.lootGuid = packet.readUInt64(); + data.lootType = packet.readUInt8(); + data.gold = packet.readUInt32(); + uint8_t itemCount = packet.readUInt8(); + + data.items.reserve(itemCount); + for (uint8_t i = 0; i < itemCount; ++i) { + LootItem item; + item.slotIndex = packet.readUInt8(); + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.randomSuffix = packet.readUInt32(); + item.randomPropertyId = packet.readUInt32(); + item.lootSlotType = packet.readUInt8(); + data.items.push_back(item); + } + + LOG_INFO("Loot response: ", (int)itemCount, " items, ", data.gold, " copper"); + return true; +} + +// ============================================================ +// Phase 5: NPC Gossip +// ============================================================ + +network::Packet GossipHelloPacket::build(uint64_t npcGuid) { + network::Packet packet(static_cast(Opcode::CMSG_GOSSIP_HELLO)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t optionId, const std::string& code) { + network::Packet packet(static_cast(Opcode::CMSG_GOSSIP_SELECT_OPTION)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(optionId); + if (!code.empty()) { + packet.writeString(code); + } + return packet; +} + +bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) { + data.npcGuid = packet.readUInt64(); + data.menuId = packet.readUInt32(); + data.titleTextId = packet.readUInt32(); + uint32_t optionCount = packet.readUInt32(); + + data.options.reserve(optionCount); + for (uint32_t i = 0; i < optionCount; ++i) { + GossipOption opt; + opt.id = packet.readUInt32(); + opt.icon = packet.readUInt8(); + opt.isCoded = (packet.readUInt8() != 0); + opt.boxMoney = packet.readUInt32(); + opt.text = packet.readString(); + opt.boxText = packet.readString(); + data.options.push_back(opt); + } + + uint32_t questCount = packet.readUInt32(); + data.quests.reserve(questCount); + for (uint32_t i = 0; i < questCount; ++i) { + GossipQuestItem quest; + quest.questId = packet.readUInt32(); + quest.questIcon = packet.readUInt32(); + quest.questLevel = static_cast(packet.readUInt32()); + quest.questFlags = packet.readUInt32(); + quest.isRepeatable = packet.readUInt8(); + quest.title = packet.readString(); + data.quests.push_back(quest); + } + + LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests"); + return true; +} + +// ============================================================ +// Phase 5: Vendor +// ============================================================ + +network::Packet ListInventoryPacket::build(uint64_t npcGuid) { + network::Packet packet(static_cast(Opcode::CMSG_LIST_INVENTORY)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) { + network::Packet packet(static_cast(Opcode::CMSG_BUY_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt32(itemId); + packet.writeUInt32(slot); + packet.writeUInt8(count); + return packet; +} + +network::Packet SellItemPacket::build(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count) { + network::Packet packet(static_cast(Opcode::CMSG_SELL_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(itemGuid); + packet.writeUInt8(count); + return packet; +} + +bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data) { + data.vendorGuid = packet.readUInt64(); + uint8_t itemCount = packet.readUInt8(); + + if (itemCount == 0) { + LOG_INFO("Vendor has nothing for sale"); + return true; + } + + data.items.reserve(itemCount); + for (uint8_t i = 0; i < itemCount; ++i) { + VendorItem item; + item.slot = packet.readUInt32(); + item.itemId = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.maxCount = static_cast(packet.readUInt32()); + item.buyPrice = packet.readUInt32(); + item.durability = packet.readUInt32(); + item.stackCount = packet.readUInt32(); + item.extendedCost = packet.readUInt32(); + data.items.push_back(item); + } + + LOG_INFO("Vendor inventory: ", (int)itemCount, " items"); + return true; +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c8e85ece..c6a7d4da 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -54,21 +54,6 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Process targeting input before UI windows processTargetInput(gameHandler); - // Main menu bar - if (ImGui::BeginMainMenuBar()) { - if (ImGui::BeginMenu("View")) { - ImGui::MenuItem("Player Info", nullptr, &showPlayerInfo); - ImGui::MenuItem("Entity List", nullptr, &showEntityWindow); - ImGui::MenuItem("Chat", nullptr, &showChatWindow); - bool invOpen = inventoryScreen.isOpen(); - if (ImGui::MenuItem("Inventory", "B", &invOpen)) { - inventoryScreen.setOpen(invOpen); - } - ImGui::EndMenu(); - } - ImGui::EndMainMenuBar(); - } - // Player unit frame (top-left) renderPlayerFrame(gameHandler); @@ -90,6 +75,17 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderChatWindow(gameHandler); } + // ---- New UI elements ---- + renderActionBar(gameHandler); + renderCastBar(gameHandler); + renderCombatText(gameHandler); + renderPartyFrames(gameHandler); + renderGroupInvitePopup(gameHandler); + renderBuffBar(gameHandler); + renderLootWindow(gameHandler); + renderGossipWindow(gameHandler); + renderVendorWindow(gameHandler); + // Inventory (B key toggle handled inside) inventoryScreen.render(gameHandler.getInventory()); @@ -346,7 +342,40 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { - gameHandler.clearTarget(); + if (gameHandler.isCasting()) { + gameHandler.cancelCast(); + } else if (gameHandler.isLootWindowOpen()) { + gameHandler.closeLoot(); + } else if (gameHandler.isGossipWindowOpen()) { + gameHandler.closeGossip(); + } else { + gameHandler.clearTarget(); + } + } + + // Auto-attack (T key) + if (input.isKeyJustPressed(SDL_SCANCODE_T)) { + if (gameHandler.hasTarget() && !gameHandler.isAutoAttacking()) { + gameHandler.startAutoAttack(gameHandler.getTargetGuid()); + } else if (gameHandler.isAutoAttacking()) { + gameHandler.stopAutoAttack(); + } + } + + // Action bar keys (1-9, 0, -, =) + static const SDL_Scancode actionBarKeys[] = { + SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, + SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, + SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS + }; + for (int i = 0; i < 12; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) { + const auto& bar = gameHandler.getActionBar(); + if (bar[i].type == game::ActionBarSlot::SPELL && bar[i].isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bar[i].id, target); + } + } } } @@ -389,6 +418,27 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { // Don't clear on miss — left-click is also used for camera orbit } } + + // Right-click on target for NPC interaction / loot / auto-attack + if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT)) { + if (gameHandler.hasTarget()) { + auto target = gameHandler.getTarget(); + if (target) { + if (target->getType() == game::ObjectType::UNIT) { + // Check if unit is dead (health == 0) → loot, otherwise interact/attack + auto unit = std::static_pointer_cast(target); + if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { + gameHandler.lootTarget(target->getGuid()); + } else { + // Try NPC interaction first (gossip), fall back to attack + gameHandler.interactWithNpc(target->getGuid()); + } + } else if (target->getType() == game::ObjectType::PLAYER) { + // Right-click another player could start attack in PvP context + } + } + } + } } void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { @@ -426,6 +476,16 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextDisabled("Lv %u", playerLevel); + // Try to get real HP/mana from the player entity + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) { + playerHp = unit->getHealth(); + playerMaxHp = unit->getMaxHealth(); + } + } + // Health bar float pct = static_cast(playerHp) / static_cast(playerMaxHp); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); @@ -433,6 +493,29 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); ImGui::PopStyleColor(); + + // Mana/Power bar (Phase 2) + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + uint32_t power = unit->getPower(); + uint32_t maxPower = unit->getMaxPower(); + if (maxPower > 0) { + float mpPct = static_cast(power) / static_cast(maxPower); + // Color by power type + ImVec4 powerColor; + switch (unit->getPowerType()) { + case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) + case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) + case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) + default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); + char mpOverlay[64]; + snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpOverlay); + ImGui::PopStyleColor(); + } + } } ImGui::End(); @@ -500,6 +583,17 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); ImGui::PopStyleColor(); + // Target mana bar + uint32_t targetPower = unit->getPower(); + uint32_t targetMaxPower = unit->getMaxPower(); + if (targetMaxPower > 0) { + float mpPct = static_cast(targetPower) / static_cast(targetMaxPower); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.2f, 0.9f, 1.0f)); + char mpOverlay[64]; + snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpOverlay); + ImGui::PopStyleColor(); + } } else { ImGui::TextDisabled("No health data"); } @@ -561,6 +655,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { chatInputBuffer[0] = '\0'; return; } + // /invite command (Phase 4) + if (command.size() > 7 && command.substr(0, 7) == "invite ") { + std::string targetName = input.substr(8); + gameHandler.inviteToGroup(targetName); + chatInputBuffer[0] = '\0'; + return; + } + // Not a recognized emote — fall through and send as normal chat } @@ -858,4 +960,548 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { } } +// ============================================================ +// Action Bar (Phase 3) +// ============================================================ + +void GameScreen::renderActionBar(game::GameHandler& gameHandler) { + 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 slotSize = 48.0f; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH = slotSize + 24.0f; + float barX = (screenW - barW) / 2.0f; + float barY = screenH - barH; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##ActionBar", nullptr, flags)) { + const auto& bar = gameHandler.getActionBar(); + static const char* keyLabels[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + + for (int i = 0; i < 12; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + + ImGui::BeginGroup(); + ImGui::PushID(i); + + const auto& slot = bar[i]; + bool onCooldown = !slot.isReady(); + + if (onCooldown) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + } else if (slot.isEmpty()) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + } + + char label[32]; + if (slot.type == game::ActionBarSlot::SPELL) { + snprintf(label, sizeof(label), "S%u", slot.id); + } else { + snprintf(label, sizeof(label), "--"); + } + + if (ImGui::Button(label, ImVec2(slotSize, slotSize))) { + if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } + } + ImGui::PopStyleColor(); + + // Cooldown overlay text + if (onCooldown) { + char cdText[16]; + snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - slotSize / 2 - 8); + ImGui::TextColored(ImVec4(1, 1, 0, 1), "%s", cdText); + } + + // Key label below + ImGui::TextDisabled("%s", keyLabels[i]); + + ImGui::PopID(); + ImGui::EndGroup(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Cast Bar (Phase 3) +// ============================================================ + +void GameScreen::renderCastBar(game::GameHandler& gameHandler) { + if (!gameHandler.isCasting()) 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 barW = 300.0f; + float barX = (screenW - barW) / 2.0f; + float barY = screenH - 120.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); + + if (ImGui::Begin("##CastBar", nullptr, flags)) { + float progress = gameHandler.getCastProgress(); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.6f, 0.2f, 1.0f)); + + char overlay[64]; + snprintf(overlay, sizeof(overlay), "Spell %u (%.1fs)", + gameHandler.getCurrentCastSpellId(), gameHandler.getCastTimeRemaining()); + ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + ImGui::PopStyleColor(); + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Floating Combat Text (Phase 2) +// ============================================================ + +void GameScreen::renderCombatText(game::GameHandler& gameHandler) { + const auto& entries = gameHandler.getCombatText(); + if (entries.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Render combat text entries overlaid on screen + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + + if (ImGui::Begin("##CombatText", nullptr, flags)) { + float centerX = screenW / 2.0f; + int index = 0; + for (const auto& entry : entries) { + float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + float yOffset = 200.0f - entry.age * 60.0f; + + ImVec4 color; + char text[64]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = entry.isPlayerSource ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow + ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = ImVec4(1.0f, 0.5f, 0.0f, alpha); // Orange for crit + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), "Dodge"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), "Parry"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // Stagger entries horizontally + float xOffset = centerX + (index % 3 - 1) * 80.0f; + ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); + ImGui::TextColored(color, "%s", text); + index++; + } + } + ImGui::End(); +} + +// ============================================================ +// Party Frames (Phase 4) +// ============================================================ + +void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { + if (!gameHandler.isInGroup()) return; + + const auto& partyData = gameHandler.getPartyData(); + float frameY = 120.0f; + + ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); + + if (ImGui::Begin("##PartyFrames", nullptr, flags)) { + for (const auto& member : partyData.members) { + ImGui::PushID(static_cast(member.guid)); + + ImVec4 nameColor = member.isOnline ? + ImVec4(0.3f, 0.8f, 1.0f, 1.0f) : + ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + + // Clickable name to target + if (ImGui::Selectable(member.name.c_str(), gameHandler.getTargetGuid() == member.guid)) { + gameHandler.setTarget(member.guid); + } + + // Try to show health from entity + auto entity = gameHandler.getEntityManager().getEntity(member.guid); + if (entity && (entity->getType() == game::ObjectType::PLAYER || entity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(entity); + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) : + ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); + ImGui::ProgressBar(pct, ImVec2(-1, 12), ""); + ImGui::PopStyleColor(); + } + } + + ImGui::Separator(); + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Group Invite Popup (Phase 4) +// ============================================================ + +void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingGroupInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Group Invite", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::Text("%s has invited you to a group.", gameHandler.getPendingInviterName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptGroupInvite(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineGroupInvite(); + } + } + ImGui::End(); +} + +// ============================================================ +// Buff/Debuff Bar (Phase 3) +// ============================================================ + +void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { + const auto& auras = gameHandler.getPlayerAuras(); + if (auras.empty()) return; + + // Count non-empty auras + int activeCount = 0; + for (const auto& a : auras) { + if (!a.isEmpty()) activeCount++; + } + if (activeCount == 0) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW - 400, 30), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(390, 0), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + + if (ImGui::Begin("##BuffBar", nullptr, flags)) { + int shown = 0; + for (size_t i = 0; i < auras.size() && shown < 16; ++i) { + const auto& aura = auras[i]; + if (aura.isEmpty()) continue; + + if (shown > 0 && shown % 8 != 0) ImGui::SameLine(); + + ImGui::PushID(static_cast(i)); + + // Green border for buffs, red for debuffs + bool isBuff = (aura.flags & 0x02) != 0; // POSITIVE flag + ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : ImVec4(0.8f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Button, borderColor); + + char label[16]; + snprintf(label, sizeof(label), "%u", aura.spellId); + if (ImGui::Button(label, ImVec2(40, 40))) { + // Right-click to cancel own buffs + if (isBuff) { + gameHandler.cancelAura(aura.spellId); + } + } + ImGui::PopStyleColor(); + + // Duration text + if (aura.durationMs > 0) { + int seconds = aura.durationMs / 1000; + if (seconds < 60) { + ImGui::Text("%ds", seconds); + } else { + ImGui::Text("%dm", seconds / 60); + } + } + + ImGui::PopID(); + shown++; + } + } + ImGui::End(); + + ImGui::PopStyleColor(); +} + +// ============================================================ +// Loot Window (Phase 5) +// ============================================================ + +void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isLootWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& loot = gameHandler.getCurrentLoot(); + + // Gold + if (loot.gold > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc", + loot.getGold(), loot.getSilver(), loot.getCopper()); + ImGui::Separator(); + } + + // Items + for (const auto& item : loot.items) { + ImGui::PushID(item.slotIndex); + char label[64]; + snprintf(label, sizeof(label), "Item %u (x%u)", item.itemId, item.count); + if (ImGui::Selectable(label)) { + gameHandler.lootItem(item.slotIndex); + } + ImGui::PopID(); + } + + if (loot.items.empty() && loot.gold == 0) { + ImGui::TextDisabled("Empty"); + } + + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeLoot(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeLoot(); + } +} + +// ============================================================ +// Gossip Window (Phase 5) +// ============================================================ + +void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isGossipWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("NPC Dialog", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& gossip = gameHandler.getCurrentGossip(); + + // NPC name (from creature cache) + auto npcEntity = gameHandler.getEntityManager().getEntity(gossip.npcGuid); + if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(npcEntity); + if (!unit->getName().empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); + ImGui::Separator(); + } + } + + ImGui::Spacing(); + + // Gossip options + static const char* gossipIcons[] = {"[Chat]", "[Vendor]", "[Taxi]", "[Trainer]", "[Spiritguide]", + "[Tabardvendor]", "[Battlemaster]", "[Banker]", "[Petitioner]", + "[Tabarddesigner]", "[Auctioneer]"}; + + for (const auto& opt : gossip.options) { + ImGui::PushID(static_cast(opt.id)); + const char* icon = (opt.icon < 11) ? gossipIcons[opt.icon] : "[Option]"; + char label[256]; + snprintf(label, sizeof(label), "%s %s", icon, opt.text.c_str()); + if (ImGui::Selectable(label)) { + gameHandler.selectGossipOption(opt.id); + } + ImGui::PopID(); + } + + // Quest items + if (!gossip.quests.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Quests:"); + for (const auto& quest : gossip.quests) { + ImGui::BulletText("[%d] %s", quest.questLevel, quest.title.c_str()); + } + } + + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeGossip(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeGossip(); + } +} + +// ============================================================ +// Vendor Window (Phase 5) +// ============================================================ + +void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isVendorWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 100), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_FirstUseEver); + + bool open = true; + if (ImGui::Begin("Vendor", &open)) { + const auto& vendor = gameHandler.getVendorItems(); + + if (vendor.items.empty()) { + ImGui::TextDisabled("This vendor has nothing for sale."); + } else { + if (ImGui::BeginTable("VendorTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableHeadersRow(); + + for (const auto& item : vendor.items) { + ImGui::TableNextRow(); + ImGui::PushID(static_cast(item.slot)); + + ImGui::TableSetColumnIndex(0); + ImGui::Text("Item %u", item.itemId); + + ImGui::TableSetColumnIndex(1); + uint32_t g = item.buyPrice / 10000; + uint32_t s = (item.buyPrice / 100) % 100; + uint32_t c = item.buyPrice % 100; + ImGui::Text("%ug %us %uc", g, s, c); + + ImGui::TableSetColumnIndex(2); + if (item.maxCount < 0) { + ImGui::Text("Inf"); + } else { + ImGui::Text("%d", item.maxCount); + } + + ImGui::TableSetColumnIndex(3); + if (ImGui::SmallButton("Buy")) { + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + } + } + ImGui::End(); + + if (!open) { + // Close vendor - just hide UI, no server packet needed + // The vendor window state will be reset on next interaction + } +} + }} // namespace wowee::ui