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.
This commit is contained in:
Kelsi 2026-02-14 14:30:09 -08:00
parent ca3150e43d
commit 9bcead6a0f
14 changed files with 670 additions and 23 deletions

View file

@ -228,5 +228,14 @@
"MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9",
"SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC",
"SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", "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"
} }

View file

@ -257,5 +257,14 @@
"CMSG_TAXINODE_STATUS_QUERY": "0x1AA", "CMSG_TAXINODE_STATUS_QUERY": "0x1AA",
"SMSG_TAXINODE_STATUS": "0x1AB", "SMSG_TAXINODE_STATUS": "0x1AB",
"SMSG_INIT_EXTRA_AURA_INFO": "0x3A3", "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"
} }

View file

@ -228,5 +228,14 @@
"MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9",
"SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC",
"SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", "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"
} }

View file

@ -257,5 +257,14 @@
"CMSG_BATTLEMASTER_JOIN_ARENA": "0x0358", "CMSG_BATTLEMASTER_JOIN_ARENA": "0x0358",
"SMSG_ARENA_TEAM_STATS": "0x035B", "SMSG_ARENA_TEAM_STATS": "0x035B",
"SMSG_ARENA_ERROR": "0x0376", "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"
} }

View file

@ -229,6 +229,15 @@ public:
* @param target Target name (for whispers, empty otherwise) * @param target Target name (for whispers, empty otherwise)
*/ */
void sendChatMessage(ChatType type, const std::string& message, const std::string& target = ""); 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<std::string>& getJoinedChannels() const { return joinedChannels_; }
std::string getChannelByIndex(int index) const;
// Chat bubble callback: (senderGuid, message, isYell)
using ChatBubbleCallback = std::function<void(uint64_t, const std::string&, bool)>;
void setChatBubbleCallback(ChatBubbleCallback cb) { chatBubbleCallback_ = std::move(cb); }
/** /**
* Get chat history (recent messages) * Get chat history (recent messages)
@ -845,6 +854,9 @@ private:
* Handle SMSG_MESSAGECHAT from server * Handle SMSG_MESSAGECHAT from server
*/ */
void handleMessageChat(network::Packet& packet); void handleMessageChat(network::Packet& packet);
void handleTextEmote(network::Packet& packet);
void handleChannelNotify(network::Packet& packet);
void autoJoinDefaultChannels();
// ---- Phase 1 handlers ---- // ---- Phase 1 handlers ----
void handleNameQueryResponse(network::Packet& packet); void handleNameQueryResponse(network::Packet& packet);
@ -1031,6 +1043,8 @@ private:
// Chat // Chat
std::deque<MessageChatData> chatHistory; // Recent chat messages std::deque<MessageChatData> chatHistory; // Recent chat messages
size_t maxChatHistory = 100; // Maximum chat messages to keep size_t maxChatHistory = 100; // Maximum chat messages to keep
std::vector<std::string> joinedChannels_; // Active channel memberships
ChatBubbleCallback chatBubbleCallback_;
// Targeting // Targeting
uint64_t targetGuid = 0; uint64_t targetGuid = 0;

View file

@ -351,6 +351,19 @@ enum class LogicalOpcode : uint16_t {
SMSG_ARENA_ERROR, SMSG_ARENA_ERROR,
MSG_INSPECT_ARENA_TEAMS, 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 // Sentinel
COUNT COUNT
}; };

View file

@ -670,6 +670,115 @@ public:
*/ */
const char* getChatTypeString(ChatType type); 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 // Server Info Commands
// ============================================================ // ============================================================

View file

@ -125,6 +125,7 @@ public:
void cancelEmote(); void cancelEmote();
bool isEmoteActive() const { return emoteActive; } bool isEmoteActive() const { return emoteActive; }
static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr);
static uint32_t getEmoteDbcId(const std::string& emoteName);
// Targeting support // Targeting support
void setTargetPosition(const glm::vec3* pos); void setTargetPosition(const glm::vec3* pos);

View file

@ -49,6 +49,16 @@ private:
int lastChatType = 0; // Track chat type changes int lastChatType = 0; // Track chat type changes
bool chatInputMoveCursorToEnd = false; bool chatInputMoveCursorToEnd = false;
// Chat tabs
int activeChatTab_ = 0;
struct ChatTab {
std::string name;
uint32_t typeMask; // bitmask of ChatType values to show
};
std::vector<ChatTab> chatTabs_;
void initChatTabs();
bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const;
// UI state // UI state
bool showEntityWindow = false; bool showEntityWindow = false;
bool showChatWindow = true; bool showChatWindow = true;
@ -170,6 +180,7 @@ private:
void renderMinimapMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler);
void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler);
void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler);
void renderChatBubbles(game::GameHandler& gameHandler);
/** /**
* Inventory screen * Inventory screen
@ -209,6 +220,17 @@ private:
// Gender placeholder replacement // Gender placeholder replacement
std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); 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<ChatBubble> chatBubbles_;
bool chatBubbleCallbackSet_ = false;
// Left-click targeting: distinguish click from camera drag // Left-click targeting: distinguish click from camera drag
glm::vec2 leftClickPressPos_ = glm::vec2(0.0f); glm::vec2 leftClickPressPos_ = glm::vec2(0.0f);
bool leftClickWasPress_ = false; bool leftClickWasPress_ = false;

View file

@ -737,6 +737,18 @@ void GameHandler::handlePacket(network::Packet& packet) {
} }
break; 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: case Opcode::SMSG_QUERY_TIME_RESPONSE:
if (state == WorldState::IN_WORLD) { if (state == WorldState::IN_WORLD) {
handleQueryTimeResponse(packet); handleQueryTimeResponse(packet);
@ -1948,6 +1960,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z); worldEntryCallback_(data.mapId, data.x, data.y, data.z);
} }
// Auto-join default chat channels
autoJoinDefaultChannels();
// Auto-query guild info on login // Auto-query guild info on login
const Character* activeChar = getActiveCharacter(); const Character* activeChar = getActiveCharacter();
if (activeChar && activeChar->hasGuild() && socket) { if (activeChar && activeChar->hasGuild() && socket) {
@ -3939,6 +3954,15 @@ void GameHandler::handleMessageChat(network::Packet& packet) {
lastWhisperSender_ = data.senderName; 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 // Log the message
std::string senderInfo; std::string senderInfo;
if (!data.senderName.empty()) { if (!data.senderName.empty()) {
@ -3961,6 +3985,126 @@ void GameHandler::handleMessageChat(network::Packet& packet) {
LOG_INFO("========================================"); 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<Unit>(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<int>(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<int>(data.notifyType),
" for channel ", data.channelName);
break;
}
}
void GameHandler::autoJoinDefaultChannels() {
joinChannel("General");
joinChannel("Trade");
}
void GameHandler::setTarget(uint64_t guid) { void GameHandler::setTarget(uint64_t guid) {
if (guid == targetGuid) return; if (guid == targetGuid) return;

View file

@ -281,6 +281,15 @@ static const OpcodeNameEntry kOpcodeNames[] = {
{"SMSG_ARENA_TEAM_STATS", LogicalOpcode::SMSG_ARENA_TEAM_STATS}, {"SMSG_ARENA_TEAM_STATS", LogicalOpcode::SMSG_ARENA_TEAM_STATS},
{"SMSG_ARENA_ERROR", LogicalOpcode::SMSG_ARENA_ERROR}, {"SMSG_ARENA_ERROR", LogicalOpcode::SMSG_ARENA_ERROR},
{"MSG_INSPECT_ARENA_TEAMS", LogicalOpcode::MSG_INSPECT_ARENA_TEAMS}, {"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 // clang-format on
@ -563,6 +572,15 @@ void OpcodeTable::loadWotlkDefaults() {
{LogicalOpcode::SMSG_ARENA_TEAM_STATS, 0x035B}, {LogicalOpcode::SMSG_ARENA_TEAM_STATS, 0x035B},
{LogicalOpcode::SMSG_ARENA_ERROR, 0x0376}, {LogicalOpcode::SMSG_ARENA_ERROR, 0x0376},
{LogicalOpcode::MSG_INSPECT_ARENA_TEAMS, 0x0377}, {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(); logicalToWire_.clear();

View file

@ -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<ChannelNotifyType>(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 // Phase 1: Foundation — Targeting, Name Queries
// ============================================================ // ============================================================

View file

@ -65,6 +65,7 @@ namespace rendering {
struct EmoteInfo { struct EmoteInfo {
uint32_t animId = 0; uint32_t animId = 0;
uint32_t dbcId = 0; // EmotesText.dbc record ID (for CMSG_TEXT_EMOTE)
bool loop = false; bool loop = false;
std::string textNoTarget; std::string textNoTarget;
std::string textTarget; std::string textTarget;
@ -100,26 +101,26 @@ static bool isLoopingEmote(const std::string& command) {
static void loadFallbackEmotes() { static void loadFallbackEmotes() {
if (!EMOTE_TABLE.empty()) return; if (!EMOTE_TABLE.empty()) return;
EMOTE_TABLE = { EMOTE_TABLE = {
{"wave", {67, false, "You wave.", "You wave at %s.", "wave"}}, {"wave", {67, 0, false, "You wave.", "You wave at %s.", "wave"}},
{"bow", {66, false, "You bow down graciously.", "You bow down before %s.", "bow"}}, {"bow", {66, 0, false, "You bow down graciously.", "You bow down before %s.", "bow"}},
{"laugh", {70, false, "You laugh.", "You laugh at %s.", "laugh"}}, {"laugh", {70, 0, false, "You laugh.", "You laugh at %s.", "laugh"}},
{"point", {84, false, "You point over yonder.", "You point at %s.", "point"}}, {"point", {84, 0, false, "You point over yonder.", "You point at %s.", "point"}},
{"cheer", {68, false, "You cheer!", "You cheer at %s.", "cheer"}}, {"cheer", {68, 0, false, "You cheer!", "You cheer at %s.", "cheer"}},
{"dance", {69, true, "You burst into dance.", "You dance with %s.", "dance"}}, {"dance", {69, 0, true, "You burst into dance.", "You dance with %s.", "dance"}},
{"kneel", {75, false, "You kneel down.", "You kneel before %s.", "kneel"}}, {"kneel", {75, 0, false, "You kneel down.", "You kneel before %s.", "kneel"}},
{"applaud", {80, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "applaud"}}, {"applaud", {80, 0, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "applaud"}},
{"shout", {81, false, "You shout.", "You shout at %s.", "shout"}}, {"shout", {81, 0, false, "You shout.", "You shout at %s.", "shout"}},
{"chicken", {78, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!", {"chicken", {78, 0, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!",
"With arms flapping, you strut around %s. Cluck, Cluck, Chicken!", "chicken"}}, "With arms flapping, you strut around %s. Cluck, Cluck, Chicken!", "chicken"}},
{"cry", {77, false, "You cry.", "You cry on %s's shoulder.", "cry"}}, {"cry", {77, 0, 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"}}, {"kiss", {76, 0, 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"}}, {"roar", {74, 0, 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"}}, {"salute", {113, 0, 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"}}, {"rude", {73, 0, 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"}}, {"flex", {82, 0, 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"}}, {"shy", {83, 0, 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"}}, {"beg", {79, 0, 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"}}, {"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.clear();
EMOTE_TABLE.reserve(emotesTextDbc->getRecordCount()); EMOTE_TABLE.reserve(emotesTextDbc->getRecordCount());
for (uint32_t r = 0; r < emotesTextDbc->getRecordCount(); ++r) { 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); std::string cmdRaw = emotesTextDbc->getString(r, etL ? (*etL)["Command"] : 1);
if (cmdRaw.empty()) continue; if (cmdRaw.empty()) continue;
@ -211,6 +213,7 @@ static void loadEmotesFromDbc() {
if (cmd.empty()) continue; if (cmd.empty()) continue;
EmoteInfo info; EmoteInfo info;
info.animId = animId; info.animId = animId;
info.dbcId = recordId;
info.loop = isLoopingEmote(cmd); info.loop = isLoopingEmote(cmd);
info.textNoTarget = textNoTarget; info.textNoTarget = textNoTarget;
info.textTarget = textTarget; info.textTarget = textTarget;
@ -1569,6 +1572,15 @@ std::string Renderer::getEmoteText(const std::string& emoteName, const std::stri
return ""; 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) { void Renderer::setTargetPosition(const glm::vec3* pos) {
targetPosition = pos; targetPosition = pos;
} }

View file

@ -117,9 +117,71 @@ namespace wowee { namespace ui {
GameScreen::GameScreen() { GameScreen::GameScreen() {
loadSettings(); 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<uint8_t>(game::ChatType::SYSTEM)) |
(1u << static_cast<uint8_t>(game::ChatType::LOOT))});
// Whispers tab
chatTabs_.push_back({"Whispers", (1u << static_cast<uint8_t>(game::ChatType::WHISPER)) |
(1u << static_cast<uint8_t>(game::ChatType::WHISPER_INFORM))});
// Trade/LFG tab: channel messages
chatTabs_.push_back({"Trade/LFG", (1u << static_cast<uint8_t>(game::ChatType::CHANNEL))});
}
bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const {
if (tabIndex < 0 || tabIndex >= static_cast<int>(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<uint8_t>(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) { 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<float>(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 // Apply UI transparency setting
float prevAlpha = ImGui::GetStyle().Alpha; float prevAlpha = ImGui::GetStyle().Alpha;
ImGui::GetStyle().Alpha = uiOpacity_; ImGui::GetStyle().Alpha = uiOpacity_;
@ -185,6 +247,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderMinimapMarkers(gameHandler); renderMinimapMarkers(gameHandler);
renderDeathScreen(gameHandler); renderDeathScreen(gameHandler);
renderResurrectDialog(gameHandler); renderResurrectDialog(gameHandler);
renderChatBubbles(gameHandler);
renderEscapeMenu(); renderEscapeMenu();
renderSettingsWindow(); renderSettingsWindow();
@ -485,6 +548,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
chatWindowPos_ = ImGui::GetWindowPos(); chatWindowPos_ = ImGui::GetWindowPos();
} }
// Chat tabs
if (ImGui::BeginTabBar("ChatTabs")) {
for (int i = 0; i < static_cast<int>(chatTabs_.size()); ++i) {
if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) {
activeChatTab_ = i;
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
// Chat history // Chat history
const auto& chatHistory = gameHandler.getChatHistory(); const auto& chatHistory = gameHandler.getChatHistory();
@ -546,6 +620,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
}; };
for (const auto& msg : chatHistory) { for (const auto& msg : chatHistory) {
if (!shouldShowMessage(msg, activeChatTab_)) continue;
ImVec4 color = getChatTypeColor(msg.type); ImVec4 color = getChatTypeColor(msg.type);
if (msg.type == game::ChatType::SYSTEM) { 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) : ""; message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true; isChannelCommand = true;
switchChatType = 9; 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") { } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") {
switchChatType = 4; switchChatType = 4;
if (spacePos != std::string::npos) { if (spacePos != std::string::npos) {
@ -2003,6 +2118,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
renderer->playEmote(cmdLower); 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 // Add local chat message
game::MessageChatData msg; game::MessageChatData msg;
msg.type = game::ChatType::TEXT_EMOTE; msg.type = game::ChatType::TEXT_EMOTE;
@ -5440,6 +5562,86 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game:
return result; 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<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(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<int>(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() { void GameScreen::saveSettings() {
std::string path = getSettingsPath(); std::string path = getSettingsPath();
std::filesystem::path dir = std::filesystem::path(path).parent_path(); std::filesystem::path dir = std::filesystem::path(path).parent_path();
@ -5475,6 +5677,9 @@ void GameScreen::saveSettings() {
out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n";
out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n";
// Chat
out << "chat_active_tab=" << activeChatTab_ << "\n";
LOG_INFO("Settings saved to ", path); LOG_INFO("Settings saved to ", path);
} }
@ -5525,6 +5730,8 @@ void GameScreen::loadSettings() {
// Controls // Controls
else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); 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); 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 (...) {} } catch (...) {}
} }
LOG_INFO("Settings loaded from ", path); LOG_INFO("Settings loaded from ", path);