From 9bcead6a0ffb32fb656bf85d4b8bd356ef836aa2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Feb 2026 14:30:09 -0800 Subject: [PATCH] Add chat tabs, networked text emotes, channel system, and chat bubbles Chat tabs filter messages into General/Combat/Whispers/Trade tabs. Text emotes now send CMSG_TEXT_EMOTE to server and display incoming emotes from other players. Channel system auto-joins General/Trade on login with /join, /leave, and /1-/9 shortcuts. Chat bubbles render as ImGui overlays above entities for SAY/YELL messages with fade-out animation. --- Data/expansions/classic/opcodes.json | 11 +- Data/expansions/tbc/opcodes.json | 11 +- Data/expansions/turtle/opcodes.json | 11 +- Data/expansions/wotlk/opcodes.json | 11 +- include/game/game_handler.hpp | 14 ++ include/game/opcode_table.hpp | 13 ++ include/game/world_packets.hpp | 109 ++++++++++++++ include/rendering/renderer.hpp | 1 + include/ui/game_screen.hpp | 22 +++ src/game/game_handler.cpp | 144 +++++++++++++++++++ src/game/opcode_table.cpp | 18 +++ src/game/world_packets.cpp | 71 +++++++++ src/rendering/renderer.cpp | 50 ++++--- src/ui/game_screen.cpp | 207 +++++++++++++++++++++++++++ 14 files changed, 670 insertions(+), 23 deletions(-) diff --git a/Data/expansions/classic/opcodes.json b/Data/expansions/classic/opcodes.json index 7afbbe3b..538fe7aa 100644 --- a/Data/expansions/classic/opcodes.json +++ b/Data/expansions/classic/opcodes.json @@ -228,5 +228,14 @@ "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", - "CMSG_BATTLEMASTER_JOIN": "0x2EE" + "CMSG_BATTLEMASTER_JOIN": "0x2EE", + "CMSG_EMOTE": "0x102", + "SMSG_EMOTE": "0x103", + "CMSG_TEXT_EMOTE": "0x104", + "SMSG_TEXT_EMOTE": "0x105", + "CMSG_JOIN_CHANNEL": "0x097", + "CMSG_LEAVE_CHANNEL": "0x098", + "SMSG_CHANNEL_NOTIFY": "0x099", + "CMSG_CHANNEL_LIST": "0x09A", + "SMSG_CHANNEL_LIST": "0x09B" } diff --git a/Data/expansions/tbc/opcodes.json b/Data/expansions/tbc/opcodes.json index 7ca7795d..f9684c6e 100644 --- a/Data/expansions/tbc/opcodes.json +++ b/Data/expansions/tbc/opcodes.json @@ -257,5 +257,14 @@ "CMSG_TAXINODE_STATUS_QUERY": "0x1AA", "SMSG_TAXINODE_STATUS": "0x1AB", "SMSG_INIT_EXTRA_AURA_INFO": "0x3A3", - "SMSG_SET_EXTRA_AURA_INFO": "0x3A4" + "SMSG_SET_EXTRA_AURA_INFO": "0x3A4", + "CMSG_EMOTE": "0x102", + "SMSG_EMOTE": "0x103", + "CMSG_TEXT_EMOTE": "0x104", + "SMSG_TEXT_EMOTE": "0x105", + "CMSG_JOIN_CHANNEL": "0x097", + "CMSG_LEAVE_CHANNEL": "0x098", + "SMSG_CHANNEL_NOTIFY": "0x099", + "CMSG_CHANNEL_LIST": "0x09A", + "SMSG_CHANNEL_LIST": "0x09B" } diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index 7afbbe3b..538fe7aa 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -228,5 +228,14 @@ "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", - "CMSG_BATTLEMASTER_JOIN": "0x2EE" + "CMSG_BATTLEMASTER_JOIN": "0x2EE", + "CMSG_EMOTE": "0x102", + "SMSG_EMOTE": "0x103", + "CMSG_TEXT_EMOTE": "0x104", + "SMSG_TEXT_EMOTE": "0x105", + "CMSG_JOIN_CHANNEL": "0x097", + "CMSG_LEAVE_CHANNEL": "0x098", + "SMSG_CHANNEL_NOTIFY": "0x099", + "CMSG_CHANNEL_LIST": "0x09A", + "SMSG_CHANNEL_LIST": "0x09B" } diff --git a/Data/expansions/wotlk/opcodes.json b/Data/expansions/wotlk/opcodes.json index fb091a55..e86c339e 100644 --- a/Data/expansions/wotlk/opcodes.json +++ b/Data/expansions/wotlk/opcodes.json @@ -257,5 +257,14 @@ "CMSG_BATTLEMASTER_JOIN_ARENA": "0x0358", "SMSG_ARENA_TEAM_STATS": "0x035B", "SMSG_ARENA_ERROR": "0x0376", - "MSG_INSPECT_ARENA_TEAMS": "0x0377" + "MSG_INSPECT_ARENA_TEAMS": "0x0377", + "CMSG_EMOTE": "0x102", + "SMSG_EMOTE": "0x103", + "CMSG_TEXT_EMOTE": "0x104", + "SMSG_TEXT_EMOTE": "0x105", + "CMSG_JOIN_CHANNEL": "0x097", + "CMSG_LEAVE_CHANNEL": "0x098", + "SMSG_CHANNEL_NOTIFY": "0x099", + "CMSG_CHANNEL_LIST": "0x09A", + "SMSG_CHANNEL_LIST": "0x09B" } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index adba1031..4b063ff0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -229,6 +229,15 @@ public: * @param target Target name (for whispers, empty otherwise) */ void sendChatMessage(ChatType type, const std::string& message, const std::string& target = ""); + void sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid = 0); + void joinChannel(const std::string& channelName, const std::string& password = ""); + void leaveChannel(const std::string& channelName); + const std::vector& getJoinedChannels() const { return joinedChannels_; } + std::string getChannelByIndex(int index) const; + + // Chat bubble callback: (senderGuid, message, isYell) + using ChatBubbleCallback = std::function; + void setChatBubbleCallback(ChatBubbleCallback cb) { chatBubbleCallback_ = std::move(cb); } /** * Get chat history (recent messages) @@ -845,6 +854,9 @@ private: * Handle SMSG_MESSAGECHAT from server */ void handleMessageChat(network::Packet& packet); + void handleTextEmote(network::Packet& packet); + void handleChannelNotify(network::Packet& packet); + void autoJoinDefaultChannels(); // ---- Phase 1 handlers ---- void handleNameQueryResponse(network::Packet& packet); @@ -1031,6 +1043,8 @@ private: // Chat std::deque chatHistory; // Recent chat messages size_t maxChatHistory = 100; // Maximum chat messages to keep + std::vector joinedChannels_; // Active channel memberships + ChatBubbleCallback chatBubbleCallback_; // Targeting uint64_t targetGuid = 0; diff --git a/include/game/opcode_table.hpp b/include/game/opcode_table.hpp index 07089d43..7f793aab 100644 --- a/include/game/opcode_table.hpp +++ b/include/game/opcode_table.hpp @@ -351,6 +351,19 @@ enum class LogicalOpcode : uint16_t { SMSG_ARENA_ERROR, MSG_INSPECT_ARENA_TEAMS, + // ---- Emotes ---- + CMSG_EMOTE, + SMSG_EMOTE, + CMSG_TEXT_EMOTE, + SMSG_TEXT_EMOTE, + + // ---- Channels ---- + CMSG_JOIN_CHANNEL, + CMSG_LEAVE_CHANNEL, + SMSG_CHANNEL_NOTIFY, + CMSG_CHANNEL_LIST, + SMSG_CHANNEL_LIST, + // Sentinel COUNT }; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index ac75704d..248e7205 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -670,6 +670,115 @@ public: */ const char* getChatTypeString(ChatType type); +// ============================================================ +// Text Emotes +// ============================================================ + +/** + * CMSG_TEXT_EMOTE packet builder + */ +class TextEmotePacket { +public: + static network::Packet build(uint32_t textEmoteId, uint64_t targetGuid = 0); +}; + +/** + * SMSG_TEXT_EMOTE data + */ +struct TextEmoteData { + uint64_t senderGuid = 0; + uint32_t textEmoteId = 0; + uint32_t emoteNum = 0; + std::string targetName; + + bool isValid() const { return senderGuid != 0; } +}; + +/** + * SMSG_TEXT_EMOTE parser + */ +class TextEmoteParser { +public: + static bool parse(network::Packet& packet, TextEmoteData& data); +}; + +// ============================================================ +// Channel System +// ============================================================ + +/** + * CMSG_JOIN_CHANNEL packet builder + */ +class JoinChannelPacket { +public: + static network::Packet build(const std::string& channelName, const std::string& password = ""); +}; + +/** + * CMSG_LEAVE_CHANNEL packet builder + */ +class LeaveChannelPacket { +public: + static network::Packet build(const std::string& channelName); +}; + +/** + * Channel notification types + */ +enum class ChannelNotifyType : uint8_t { + YOU_JOINED = 0x00, + YOU_LEFT = 0x01, + WRONG_PASSWORD = 0x02, + NOT_MEMBER = 0x03, + NOT_MODERATOR = 0x04, + PASSWORD_CHANGED = 0x05, + OWNER_CHANGED = 0x06, + PLAYER_NOT_FOUND = 0x07, + NOT_OWNER = 0x08, + CHANNEL_OWNER = 0x09, + MODE_CHANGE = 0x0A, + ANNOUNCEMENTS_ON = 0x0B, + ANNOUNCEMENTS_OFF = 0x0C, + MODERATION_ON = 0x0D, + MODERATION_OFF = 0x0E, + MUTED = 0x0F, + PLAYER_KICKED = 0x10, + BANNED = 0x11, + PLAYER_BANNED = 0x12, + PLAYER_UNBANNED = 0x13, + PLAYER_NOT_BANNED = 0x14, + PLAYER_ALREADY_MEMBER = 0x15, + INVITE = 0x16, + INVITE_WRONG_FACTION = 0x17, + WRONG_FACTION = 0x18, + INVALID_NAME = 0x19, + NOT_MODERATED = 0x1A, + PLAYER_INVITED = 0x1B, + PLAYER_INVITE_BANNED = 0x1C, + THROTTLED = 0x1D, + NOT_IN_AREA = 0x1E, + NOT_IN_LFG = 0x1F, +}; + +/** + * SMSG_CHANNEL_NOTIFY data + */ +struct ChannelNotifyData { + ChannelNotifyType notifyType = ChannelNotifyType::YOU_JOINED; + std::string channelName; + uint64_t senderGuid = 0; + + bool isValid() const { return !channelName.empty(); } +}; + +/** + * SMSG_CHANNEL_NOTIFY parser + */ +class ChannelNotifyParser { +public: + static bool parse(network::Packet& packet, ChannelNotifyData& data); +}; + // ============================================================ // Server Info Commands // ============================================================ diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index a341d745..5f6a1727 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -125,6 +125,7 @@ public: void cancelEmote(); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); + static uint32_t getEmoteDbcId(const std::string& emoteName); // Targeting support void setTargetPosition(const glm::vec3* pos); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5e398b4c..1ca68a04 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -49,6 +49,16 @@ private: int lastChatType = 0; // Track chat type changes bool chatInputMoveCursorToEnd = false; + // Chat tabs + int activeChatTab_ = 0; + struct ChatTab { + std::string name; + uint32_t typeMask; // bitmask of ChatType values to show + }; + std::vector chatTabs_; + void initChatTabs(); + bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; + // UI state bool showEntityWindow = false; bool showChatWindow = true; @@ -170,6 +180,7 @@ private: void renderMinimapMarkers(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler); + void renderChatBubbles(game::GameHandler& gameHandler); /** * Inventory screen @@ -209,6 +220,17 @@ private: // Gender placeholder replacement std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); + // Chat bubbles + struct ChatBubble { + uint64_t senderGuid = 0; + std::string message; + float timeRemaining = 0.0f; + float totalDuration = 0.0f; + bool isYell = false; + }; + std::vector chatBubbles_; + bool chatBubbleCallbackSet_ = false; + // Left-click targeting: distinguish click from camera drag glm::vec2 leftClickPressPos_ = glm::vec2(0.0f); bool leftClickWasPress_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0336c776..84d1e534 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -737,6 +737,18 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + case Opcode::SMSG_TEXT_EMOTE: + if (state == WorldState::IN_WORLD) { + handleTextEmote(packet); + } + break; + + case Opcode::SMSG_CHANNEL_NOTIFY: + if (state == WorldState::IN_WORLD) { + handleChannelNotify(packet); + } + break; + case Opcode::SMSG_QUERY_TIME_RESPONSE: if (state == WorldState::IN_WORLD) { handleQueryTimeResponse(packet); @@ -1948,6 +1960,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { worldEntryCallback_(data.mapId, data.x, data.y, data.z); } + // Auto-join default chat channels + autoJoinDefaultChannels(); + // Auto-query guild info on login const Character* activeChar = getActiveCharacter(); if (activeChar && activeChar->hasGuild() && socket) { @@ -3939,6 +3954,15 @@ void GameHandler::handleMessageChat(network::Packet& packet) { lastWhisperSender_ = data.senderName; } + // Trigger chat bubble for SAY/YELL messages from others + if (chatBubbleCallback_ && data.senderGuid != 0) { + if (data.type == ChatType::SAY || data.type == ChatType::YELL || + data.type == ChatType::MONSTER_SAY || data.type == ChatType::MONSTER_YELL) { + bool isYell = (data.type == ChatType::YELL || data.type == ChatType::MONSTER_YELL); + chatBubbleCallback_(data.senderGuid, data.message, isYell); + } + } + // Log the message std::string senderInfo; if (!data.senderName.empty()) { @@ -3961,6 +3985,126 @@ void GameHandler::handleMessageChat(network::Packet& packet) { LOG_INFO("========================================"); } +void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = TextEmotePacket::build(textEmoteId, targetGuid); + socket->send(packet); +} + +void GameHandler::handleTextEmote(network::Packet& packet) { + TextEmoteData data; + if (!TextEmoteParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE"); + return; + } + + // Skip our own text emotes (we already have local echo) + if (data.senderGuid == playerGuid && data.senderGuid != 0) { + return; + } + + // Resolve sender name + std::string senderName; + auto nameIt = playerNameCache.find(data.senderGuid); + if (nameIt != playerNameCache.end()) { + senderName = nameIt->second; + } else { + auto entity = entityManager.getEntity(data.senderGuid); + if (entity) { + auto unit = std::dynamic_pointer_cast(entity); + if (unit) senderName = unit->getName(); + } + } + if (senderName.empty()) { + senderName = "Unknown"; + queryPlayerName(data.senderGuid); + } + + // Build emote message text (server sends textEmoteId, we look up the text) + // For now, just display a generic emote message + MessageChatData chatMsg; + chatMsg.type = ChatType::TEXT_EMOTE; + chatMsg.language = ChatLanguage::COMMON; + chatMsg.senderGuid = data.senderGuid; + chatMsg.senderName = senderName; + chatMsg.message = data.targetName.empty() + ? senderName + " performs an emote." + : senderName + " performs an emote at " + data.targetName + "."; + + chatHistory.push_back(chatMsg); + if (chatHistory.size() > maxChatHistory) { + chatHistory.erase(chatHistory.begin()); + } + + LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ")"); +} + +void GameHandler::joinChannel(const std::string& channelName, const std::string& password) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = JoinChannelPacket::build(channelName, password); + socket->send(packet); + LOG_INFO("Requesting to join channel: ", channelName); +} + +void GameHandler::leaveChannel(const std::string& channelName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = LeaveChannelPacket::build(channelName); + socket->send(packet); + LOG_INFO("Requesting to leave channel: ", channelName); +} + +std::string GameHandler::getChannelByIndex(int index) const { + if (index < 1 || index > static_cast(joinedChannels_.size())) return ""; + return joinedChannels_[index - 1]; +} + +void GameHandler::handleChannelNotify(network::Packet& packet) { + ChannelNotifyData data; + if (!ChannelNotifyParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_CHANNEL_NOTIFY"); + return; + } + + switch (data.notifyType) { + case ChannelNotifyType::YOU_JOINED: { + // Add to active channels if not already present + bool found = false; + for (const auto& ch : joinedChannels_) { + if (ch == data.channelName) { found = true; break; } + } + if (!found) { + joinedChannels_.push_back(data.channelName); + } + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.message = "Joined channel: " + data.channelName; + addLocalChatMessage(msg); + LOG_INFO("Joined channel: ", data.channelName); + break; + } + case ChannelNotifyType::YOU_LEFT: { + joinedChannels_.erase( + std::remove(joinedChannels_.begin(), joinedChannels_.end(), data.channelName), + joinedChannels_.end()); + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.message = "Left channel: " + data.channelName; + addLocalChatMessage(msg); + LOG_INFO("Left channel: ", data.channelName); + break; + } + default: + LOG_DEBUG("Channel notify type ", static_cast(data.notifyType), + " for channel ", data.channelName); + break; + } +} + +void GameHandler::autoJoinDefaultChannels() { + joinChannel("General"); + joinChannel("Trade"); +} + void GameHandler::setTarget(uint64_t guid) { if (guid == targetGuid) return; diff --git a/src/game/opcode_table.cpp b/src/game/opcode_table.cpp index 74a132f6..0cedea36 100644 --- a/src/game/opcode_table.cpp +++ b/src/game/opcode_table.cpp @@ -281,6 +281,15 @@ static const OpcodeNameEntry kOpcodeNames[] = { {"SMSG_ARENA_TEAM_STATS", LogicalOpcode::SMSG_ARENA_TEAM_STATS}, {"SMSG_ARENA_ERROR", LogicalOpcode::SMSG_ARENA_ERROR}, {"MSG_INSPECT_ARENA_TEAMS", LogicalOpcode::MSG_INSPECT_ARENA_TEAMS}, + {"CMSG_EMOTE", LogicalOpcode::CMSG_EMOTE}, + {"SMSG_EMOTE", LogicalOpcode::SMSG_EMOTE}, + {"CMSG_TEXT_EMOTE", LogicalOpcode::CMSG_TEXT_EMOTE}, + {"SMSG_TEXT_EMOTE", LogicalOpcode::SMSG_TEXT_EMOTE}, + {"CMSG_JOIN_CHANNEL", LogicalOpcode::CMSG_JOIN_CHANNEL}, + {"CMSG_LEAVE_CHANNEL", LogicalOpcode::CMSG_LEAVE_CHANNEL}, + {"SMSG_CHANNEL_NOTIFY", LogicalOpcode::SMSG_CHANNEL_NOTIFY}, + {"CMSG_CHANNEL_LIST", LogicalOpcode::CMSG_CHANNEL_LIST}, + {"SMSG_CHANNEL_LIST", LogicalOpcode::SMSG_CHANNEL_LIST}, }; // clang-format on @@ -563,6 +572,15 @@ void OpcodeTable::loadWotlkDefaults() { {LogicalOpcode::SMSG_ARENA_TEAM_STATS, 0x035B}, {LogicalOpcode::SMSG_ARENA_ERROR, 0x0376}, {LogicalOpcode::MSG_INSPECT_ARENA_TEAMS, 0x0377}, + {LogicalOpcode::CMSG_EMOTE, 0x102}, + {LogicalOpcode::SMSG_EMOTE, 0x103}, + {LogicalOpcode::CMSG_TEXT_EMOTE, 0x104}, + {LogicalOpcode::SMSG_TEXT_EMOTE, 0x105}, + {LogicalOpcode::CMSG_JOIN_CHANNEL, 0x097}, + {LogicalOpcode::CMSG_LEAVE_CHANNEL, 0x098}, + {LogicalOpcode::SMSG_CHANNEL_NOTIFY, 0x099}, + {LogicalOpcode::CMSG_CHANNEL_LIST, 0x09A}, + {LogicalOpcode::SMSG_CHANNEL_LIST, 0x09B}, }; logicalToWire_.clear(); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5f610d92..d36518f9 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1293,6 +1293,77 @@ const char* getChatTypeString(ChatType type) { } } +// ============================================================ +// Text Emotes +// ============================================================ + +network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_TEXT_EMOTE)); + packet.writeUInt32(textEmoteId); + packet.writeUInt32(0); // emoteNum (unused) + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_TEXT_EMOTE: emoteId=", textEmoteId, " target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data) { + size_t bytesLeft = packet.getSize() - packet.getReadPos(); + if (bytesLeft < 20) { + LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes"); + return false; + } + data.senderGuid = packet.readUInt64(); + data.textEmoteId = packet.readUInt32(); + data.emoteNum = packet.readUInt32(); + uint32_t nameLen = packet.readUInt32(); + if (nameLen > 0 && nameLen <= 256) { + data.targetName = packet.readString(); + } else if (nameLen > 0) { + // Skip garbage + return false; + } + return true; +} + +// ============================================================ +// Channel System +// ============================================================ + +network::Packet JoinChannelPacket::build(const std::string& channelName, const std::string& password) { + network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL)); + packet.writeUInt32(0); // channelId (unused) + packet.writeUInt8(0); // hasVoice + packet.writeUInt8(0); // joinedByZone + packet.writeString(channelName); + packet.writeString(password); + LOG_DEBUG("Built CMSG_JOIN_CHANNEL: channel=", channelName); + return packet; +} + +network::Packet LeaveChannelPacket::build(const std::string& channelName) { + network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL)); + packet.writeUInt32(0); // channelId (unused) + packet.writeString(channelName); + LOG_DEBUG("Built CMSG_LEAVE_CHANNEL: channel=", channelName); + return packet; +} + +bool ChannelNotifyParser::parse(network::Packet& packet, ChannelNotifyData& data) { + size_t bytesLeft = packet.getSize() - packet.getReadPos(); + if (bytesLeft < 2) { + LOG_WARNING("SMSG_CHANNEL_NOTIFY too short"); + return false; + } + data.notifyType = static_cast(packet.readUInt8()); + data.channelName = packet.readString(); + // Some notification types have additional fields (guid, etc.) + bytesLeft = packet.getSize() - packet.getReadPos(); + if (bytesLeft >= 8) { + data.senderGuid = packet.readUInt64(); + } + return true; +} + // ============================================================ // Phase 1: Foundation — Targeting, Name Queries // ============================================================ diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 1e76e15b..85d4e223 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -65,6 +65,7 @@ namespace rendering { struct EmoteInfo { uint32_t animId = 0; + uint32_t dbcId = 0; // EmotesText.dbc record ID (for CMSG_TEXT_EMOTE) bool loop = false; std::string textNoTarget; std::string textTarget; @@ -100,26 +101,26 @@ static bool isLoopingEmote(const std::string& command) { static void loadFallbackEmotes() { if (!EMOTE_TABLE.empty()) return; EMOTE_TABLE = { - {"wave", {67, false, "You wave.", "You wave at %s.", "wave"}}, - {"bow", {66, false, "You bow down graciously.", "You bow down before %s.", "bow"}}, - {"laugh", {70, false, "You laugh.", "You laugh at %s.", "laugh"}}, - {"point", {84, false, "You point over yonder.", "You point at %s.", "point"}}, - {"cheer", {68, false, "You cheer!", "You cheer at %s.", "cheer"}}, - {"dance", {69, true, "You burst into dance.", "You dance with %s.", "dance"}}, - {"kneel", {75, false, "You kneel down.", "You kneel before %s.", "kneel"}}, - {"applaud", {80, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "applaud"}}, - {"shout", {81, false, "You shout.", "You shout at %s.", "shout"}}, - {"chicken", {78, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!", + {"wave", {67, 0, false, "You wave.", "You wave at %s.", "wave"}}, + {"bow", {66, 0, false, "You bow down graciously.", "You bow down before %s.", "bow"}}, + {"laugh", {70, 0, false, "You laugh.", "You laugh at %s.", "laugh"}}, + {"point", {84, 0, false, "You point over yonder.", "You point at %s.", "point"}}, + {"cheer", {68, 0, false, "You cheer!", "You cheer at %s.", "cheer"}}, + {"dance", {69, 0, true, "You burst into dance.", "You dance with %s.", "dance"}}, + {"kneel", {75, 0, false, "You kneel down.", "You kneel before %s.", "kneel"}}, + {"applaud", {80, 0, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "applaud"}}, + {"shout", {81, 0, false, "You shout.", "You shout at %s.", "shout"}}, + {"chicken", {78, 0, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!", "With arms flapping, you strut around %s. Cluck, Cluck, Chicken!", "chicken"}}, - {"cry", {77, false, "You cry.", "You cry on %s's shoulder.", "cry"}}, - {"kiss", {76, false, "You blow a kiss into the wind.", "You blow a kiss to %s.", "kiss"}}, - {"roar", {74, false, "You roar with bestial vigor. So fierce!", "You roar with bestial vigor at %s. So fierce!", "roar"}}, - {"salute", {113, false, "You salute.", "You salute %s with respect.", "salute"}}, - {"rude", {73, false, "You make a rude gesture.", "You make a rude gesture at %s.", "rude"}}, - {"flex", {82, false, "You flex your muscles. Oooooh so strong!", "You flex at %s. Oooooh so strong!", "flex"}}, - {"shy", {83, false, "You smile shyly.", "You smile shyly at %s.", "shy"}}, - {"beg", {79, false, "You beg everyone around you. How pathetic.", "You beg %s. How pathetic.", "beg"}}, - {"eat", {61, false, "You begin to eat.", "You begin to eat in front of %s.", "eat"}}, + {"cry", {77, 0, false, "You cry.", "You cry on %s's shoulder.", "cry"}}, + {"kiss", {76, 0, false, "You blow a kiss into the wind.", "You blow a kiss to %s.", "kiss"}}, + {"roar", {74, 0, false, "You roar with bestial vigor. So fierce!", "You roar with bestial vigor at %s. So fierce!", "roar"}}, + {"salute", {113, 0, false, "You salute.", "You salute %s with respect.", "salute"}}, + {"rude", {73, 0, false, "You make a rude gesture.", "You make a rude gesture at %s.", "rude"}}, + {"flex", {82, 0, false, "You flex your muscles. Oooooh so strong!", "You flex at %s. Oooooh so strong!", "flex"}}, + {"shy", {83, 0, false, "You smile shyly.", "You smile shyly at %s.", "shy"}}, + {"beg", {79, 0, false, "You beg everyone around you. How pathetic.", "You beg %s. How pathetic.", "beg"}}, + {"eat", {61, 0, false, "You begin to eat.", "You begin to eat in front of %s.", "eat"}}, }; } @@ -183,6 +184,7 @@ static void loadEmotesFromDbc() { EMOTE_TABLE.clear(); EMOTE_TABLE.reserve(emotesTextDbc->getRecordCount()); for (uint32_t r = 0; r < emotesTextDbc->getRecordCount(); ++r) { + uint32_t recordId = emotesTextDbc->getUInt32(r, etL ? (*etL)["ID"] : 0); std::string cmdRaw = emotesTextDbc->getString(r, etL ? (*etL)["Command"] : 1); if (cmdRaw.empty()) continue; @@ -211,6 +213,7 @@ static void loadEmotesFromDbc() { if (cmd.empty()) continue; EmoteInfo info; info.animId = animId; + info.dbcId = recordId; info.loop = isLoopingEmote(cmd); info.textNoTarget = textNoTarget; info.textTarget = textTarget; @@ -1569,6 +1572,15 @@ std::string Renderer::getEmoteText(const std::string& emoteName, const std::stri return ""; } +uint32_t Renderer::getEmoteDbcId(const std::string& emoteName) { + loadEmotesFromDbc(); + auto it = EMOTE_TABLE.find(emoteName); + if (it != EMOTE_TABLE.end()) { + return it->second.dbcId; + } + return 0; +} + void Renderer::setTargetPosition(const glm::vec3* pos) { targetPosition = pos; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cfd24ab1..fbc3ae0c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -117,9 +117,71 @@ namespace wowee { namespace ui { GameScreen::GameScreen() { loadSettings(); + initChatTabs(); +} + +void GameScreen::initChatTabs() { + chatTabs_.clear(); + // General tab: shows everything + chatTabs_.push_back({"General", 0xFFFFFFFF}); + // Combat tab: system + loot messages + chatTabs_.push_back({"Combat", (1u << static_cast(game::ChatType::SYSTEM)) | + (1u << static_cast(game::ChatType::LOOT))}); + // Whispers tab + chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | + (1u << static_cast(game::ChatType::WHISPER_INFORM))}); + // Trade/LFG tab: channel messages + chatTabs_.push_back({"Trade/LFG", (1u << static_cast(game::ChatType::CHANNEL))}); +} + +bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { + if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; + const auto& tab = chatTabs_[tabIndex]; + if (tab.typeMask == 0xFFFFFFFF) return true; // General tab shows all + + uint32_t typeBit = 1u << static_cast(msg.type); + + // For Trade/LFG tab, also filter by channel name + if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) { + const std::string& ch = msg.channelName; + if (ch.find("Trade") == std::string::npos && + ch.find("General") == std::string::npos && + ch.find("LookingForGroup") == std::string::npos) { + return false; + } + return true; + } + + return (tab.typeMask & typeBit) != 0; } void GameScreen::render(game::GameHandler& gameHandler) { + // Set up chat bubble callback (once) + if (!chatBubbleCallbackSet_) { + gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { + float duration = 8.0f + static_cast(msg.size()) * 0.06f; + if (isYell) duration += 2.0f; + if (duration > 15.0f) duration = 15.0f; + + // Replace existing bubble for same sender + for (auto& b : chatBubbles_) { + if (b.senderGuid == guid) { + b.message = msg; + b.timeRemaining = duration; + b.totalDuration = duration; + b.isYell = isYell; + return; + } + } + // Evict oldest if too many + if (chatBubbles_.size() >= 10) { + chatBubbles_.erase(chatBubbles_.begin()); + } + chatBubbles_.push_back({guid, msg, duration, duration, isYell}); + }); + chatBubbleCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -185,6 +247,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); renderResurrectDialog(gameHandler); + renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -485,6 +548,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatWindowPos_ = ImGui::GetWindowPos(); } + // Chat tabs + if (ImGui::BeginTabBar("ChatTabs")) { + for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { + if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) { + activeChatTab_ = i; + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + // Chat history const auto& chatHistory = gameHandler.getChatHistory(); @@ -546,6 +620,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { }; for (const auto& msg : chatHistory) { + if (!shouldShowMessage(msg, activeChatTab_)) continue; + ImVec4 color = getChatTypeColor(msg.type); if (msg.type == game::ChatType::SYSTEM) { @@ -1955,6 +2031,45 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; isChannelCommand = true; switchChatType = 9; + } else if (cmdLower == "join") { + // /join ChannelName [password] + if (spacePos != std::string::npos) { + std::string rest = command.substr(spacePos + 1); + size_t pwStart = rest.find(' '); + std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; + std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; + gameHandler.joinChannel(channelName, password); + } + chatInputBuffer[0] = '\0'; + return; + } else if (cmdLower == "leave") { + // /leave ChannelName + if (spacePos != std::string::npos) { + std::string channelName = command.substr(spacePos + 1); + gameHandler.leaveChannel(channelName); + } + chatInputBuffer[0] = '\0'; + return; + } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { + // /1 msg, /2 msg — channel shortcuts + int channelIdx = cmdLower[0] - '0'; + std::string channelName = gameHandler.getChannelByIndex(channelIdx); + if (!channelName.empty() && spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::CHANNEL; + target = channelName; + isChannelCommand = true; + } else if (channelName.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer[0] = '\0'; + return; + } else { + chatInputBuffer[0] = '\0'; + return; + } } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { switchChatType = 4; if (spacePos != std::string::npos) { @@ -2003,6 +2118,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { renderer->playEmote(cmdLower); } + // Send CMSG_TEXT_EMOTE to server + uint32_t dbcId = rendering::Renderer::getEmoteDbcId(cmdLower); + if (dbcId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendTextEmote(dbcId, targetGuid); + } + // Add local chat message game::MessageChatData msg; msg.type = game::ChatType::TEXT_EMOTE; @@ -5440,6 +5562,86 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: return result; } +void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) { + if (chatBubbles_.empty()) return; + + auto* renderer = core::Application::getInstance().getRenderer(); + auto* camera = renderer ? renderer->getCamera() : nullptr; + if (!camera) 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; + + // Get delta time from ImGui + float dt = ImGui::GetIO().DeltaTime; + + glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + + // Update and render bubbles + for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { + auto& bubble = chatBubbles_[i]; + bubble.timeRemaining -= dt; + if (bubble.timeRemaining <= 0.0f) { + chatBubbles_.erase(chatBubbles_.begin() + i); + continue; + } + + // Get entity position + auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); + if (!entity) continue; + + // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; // Behind camera + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float screenX = (ndc.x * 0.5f + 0.5f) * screenW; + float screenY = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Flip Y + + // Skip if off-screen + if (screenX < -200.0f || screenX > screenW + 200.0f || + screenY < -100.0f || screenY > screenH + 100.0f) continue; + + // Fade alpha over last 2 seconds + float alpha = 1.0f; + if (bubble.timeRemaining < 2.0f) { + alpha = bubble.timeRemaining / 2.0f; + } + + // Draw bubble window + std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); + ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.7f * alpha); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); + + ImGui::Begin(winId.c_str(), nullptr, flags); + + ImVec4 textColor = bubble.isYell + ? ImVec4(1.0f, 0.2f, 0.2f, alpha) + : ImVec4(1.0f, 1.0f, 1.0f, alpha); + + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + ImGui::PushTextWrapPos(200.0f); + ImGui::TextWrapped("%s", bubble.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + + ImGui::End(); + ImGui::PopStyleVar(2); + } +} + void GameScreen::saveSettings() { std::string path = getSettingsPath(); std::filesystem::path dir = std::filesystem::path(path).parent_path(); @@ -5475,6 +5677,9 @@ void GameScreen::saveSettings() { out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; + // Chat + out << "chat_active_tab=" << activeChatTab_ << "\n"; + LOG_INFO("Settings saved to ", path); } @@ -5525,6 +5730,8 @@ void GameScreen::loadSettings() { // Controls else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); + // Chat + else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); } catch (...) {} } LOG_INFO("Settings loaded from ", path);