mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
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:
parent
ca3150e43d
commit
9bcead6a0f
14 changed files with 670 additions and 23 deletions
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue