Add gameplay systems: combat, spells, groups, loot, vendors, and UI

Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:

- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
  name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
  tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
  cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
  /invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface

Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
This commit is contained in:
Kelsi 2026-02-04 10:30:52 -08:00
parent 6bf3fa4ed4
commit c49bb58e47
14 changed files with 3039 additions and 84 deletions

View file

@ -189,6 +189,7 @@ void Application::run() {
if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
int newWidth = event.window.data1;
int newHeight = event.window.data2;
window->setSize(newWidth, newHeight);
glViewport(0, 0, newWidth, newHeight);
if (renderer && renderer->getCamera()) {
renderer->getCamera()->setAspectRatio(static_cast<float>(newWidth) / newHeight);
@ -629,8 +630,8 @@ void Application::update(float deltaTime) {
break;
}
// Update renderer (camera, etc.)
if (renderer) {
// Update renderer (camera, etc.) only when in-game
if (renderer && state == AppState::IN_GAME) {
renderer->update(deltaTime);
}
@ -647,25 +648,13 @@ void Application::render() {
renderer->beginFrame();
// Always render atmospheric background (sky, stars, clouds, etc.)
// This provides a nice animated background for login/UI screens
// Terrain/characters only render if loaded (in-game)
switch (state) {
case AppState::IN_GAME:
// Render full world with terrain and entities
if (world) {
renderer->renderWorld(world.get());
} else {
// Fallback: render just atmosphere if world not loaded
renderer->renderWorld(nullptr);
}
break;
default:
// Login/UI screens: render atmospheric background only
// (terrain/water/characters won't render as they're not loaded)
// Only render 3D world when in-game (after server connect or single-player)
if (state == AppState::IN_GAME) {
if (world) {
renderer->renderWorld(world.get());
} else {
renderer->renderWorld(nullptr);
break;
}
}
// Render performance HUD (within ImGui frame, before UI ends the frame)

View file

@ -106,6 +106,37 @@ void GameHandler::update(float deltaTime) {
sendPing();
timeSinceLastPing = 0.0f;
}
// Update cast timer (Phase 3)
if (casting && castTimeRemaining > 0.0f) {
castTimeRemaining -= deltaTime;
if (castTimeRemaining <= 0.0f) {
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
}
// Update spell cooldowns (Phase 3)
for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) {
it->second -= deltaTime;
if (it->second <= 0.0f) {
it = spellCooldowns.erase(it);
} else {
++it;
}
}
// Update action bar cooldowns
for (auto& slot : actionBar) {
if (slot.cooldownRemaining > 0.0f) {
slot.cooldownRemaining -= deltaTime;
if (slot.cooldownRemaining < 0.0f) slot.cooldownRemaining = 0.0f;
}
}
// Update combat text (Phase 2)
updateCombatText(deltaTime);
}
}
@ -192,6 +223,127 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
// ---- Phase 1: Foundation ----
case Opcode::SMSG_NAME_QUERY_RESPONSE:
handleNameQueryResponse(packet);
break;
case Opcode::SMSG_CREATURE_QUERY_RESPONSE:
handleCreatureQueryResponse(packet);
break;
// ---- Phase 2: Combat ----
case Opcode::SMSG_ATTACKSTART:
handleAttackStart(packet);
break;
case Opcode::SMSG_ATTACKSTOP:
handleAttackStop(packet);
break;
case Opcode::SMSG_ATTACKERSTATEUPDATE:
handleAttackerStateUpdate(packet);
break;
case Opcode::SMSG_SPELLNONMELEEDAMAGELOG:
handleSpellDamageLog(packet);
break;
case Opcode::SMSG_SPELLHEALLOG:
handleSpellHealLog(packet);
break;
// ---- Phase 3: Spells ----
case Opcode::SMSG_INITIAL_SPELLS:
handleInitialSpells(packet);
break;
case Opcode::SMSG_CAST_FAILED:
handleCastFailed(packet);
break;
case Opcode::SMSG_SPELL_START:
handleSpellStart(packet);
break;
case Opcode::SMSG_SPELL_GO:
handleSpellGo(packet);
break;
case Opcode::SMSG_SPELL_FAILURE:
// Spell failed mid-cast
casting = false;
currentCastSpellId = 0;
break;
case Opcode::SMSG_SPELL_COOLDOWN:
handleSpellCooldown(packet);
break;
case Opcode::SMSG_COOLDOWN_EVENT:
handleCooldownEvent(packet);
break;
case Opcode::SMSG_AURA_UPDATE:
handleAuraUpdate(packet, false);
break;
case Opcode::SMSG_AURA_UPDATE_ALL:
handleAuraUpdate(packet, true);
break;
case Opcode::SMSG_LEARNED_SPELL:
handleLearnedSpell(packet);
break;
case Opcode::SMSG_REMOVED_SPELL:
handleRemovedSpell(packet);
break;
// ---- Phase 4: Group ----
case Opcode::SMSG_GROUP_INVITE:
handleGroupInvite(packet);
break;
case Opcode::SMSG_GROUP_DECLINE:
handleGroupDecline(packet);
break;
case Opcode::SMSG_GROUP_LIST:
handleGroupList(packet);
break;
case Opcode::SMSG_GROUP_UNINVITE:
handleGroupUninvite(packet);
break;
case Opcode::SMSG_PARTY_COMMAND_RESULT:
handlePartyCommandResult(packet);
break;
// ---- Phase 5: Loot/Gossip/Vendor ----
case Opcode::SMSG_LOOT_RESPONSE:
handleLootResponse(packet);
break;
case Opcode::SMSG_LOOT_RELEASE_RESPONSE:
handleLootReleaseResponse(packet);
break;
case Opcode::SMSG_LOOT_REMOVED:
handleLootRemoved(packet);
break;
case Opcode::SMSG_GOSSIP_MESSAGE:
handleGossipMessage(packet);
break;
case Opcode::SMSG_GOSSIP_COMPLETE:
handleGossipComplete(packet);
break;
case Opcode::SMSG_LIST_INVENTORY:
handleListInventory(packet);
break;
// Silently ignore common packets we don't handle yet
case Opcode::SMSG_FEATURE_SYSTEM_STATUS:
case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER:
case Opcode::SMSG_SET_PCT_SPELL_MODIFIER:
case Opcode::SMSG_SPELL_DELAYED:
case Opcode::SMSG_UPDATE_AURA_DURATION:
case Opcode::SMSG_PERIODICAURALOG:
case Opcode::SMSG_SPELLENERGIZELOG:
case Opcode::SMSG_ENVIRONMENTALDAMAGELOG:
case Opcode::SMSG_LOOT_MONEY_NOTIFY:
case Opcode::SMSG_LOOT_CLEAR_MONEY:
case Opcode::SMSG_NPC_TEXT_UPDATE:
case Opcode::SMSG_SELL_ITEM:
case Opcode::SMSG_BUY_FAILED:
case Opcode::SMSG_INVENTORY_CHANGE_FAILURE:
case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE:
case Opcode::MSG_RAID_TARGET_UPDATE:
case Opcode::SMSG_GROUP_SET_LEADER:
LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec);
break;
default:
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
break;
@ -357,6 +509,9 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
}
}
// Store player GUID
playerGuid = characterGuid;
// Build CMSG_PLAYER_LOGIN packet
auto packet = PlayerLoginPacket::build(characterGuid);
@ -400,6 +555,13 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
movementInfo.flags = 0;
movementInfo.flags2 = 0;
movementInfo.time = 0;
// Send CMSG_SET_ACTIVE_MOVER (required by some servers)
if (playerGuid != 0 && socket) {
auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid);
socket->send(activeMoverPacket);
LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec);
}
}
void GameHandler::handleAccountDataTimes(network::Packet& packet) {
@ -603,6 +765,35 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
// Add to manager
entityManager.addEntity(block.guid, entity);
// Auto-query names (Phase 1)
if (block.objectType == ObjectType::PLAYER) {
queryPlayerName(block.guid);
} else if (block.objectType == ObjectType::UNIT) {
// Extract creature entry from fields (UNIT_FIELD_ENTRY = index 54 in 3.3.5a,
// but the OBJECT_FIELD_ENTRY is at index 3)
auto it = block.fields.find(3); // OBJECT_FIELD_ENTRY
if (it != block.fields.end() && it->second != 0) {
auto unit = std::static_pointer_cast<Unit>(entity);
unit->setEntry(it->second);
queryCreatureInfo(it->second, block.guid);
}
}
// Extract health/mana/power from fields (Phase 2)
if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<Unit>(entity);
auto hpIt = block.fields.find(24); // UNIT_FIELD_HEALTH
if (hpIt != block.fields.end()) unit->setHealth(hpIt->second);
auto maxHpIt = block.fields.find(32); // UNIT_FIELD_MAXHEALTH
if (maxHpIt != block.fields.end()) unit->setMaxHealth(maxHpIt->second);
auto powerIt = block.fields.find(25); // UNIT_FIELD_POWER1
if (powerIt != block.fields.end()) unit->setPower(powerIt->second);
auto maxPowerIt = block.fields.find(33); // UNIT_FIELD_MAXPOWER1
if (maxPowerIt != block.fields.end()) unit->setMaxPower(maxPowerIt->second);
auto levelIt = block.fields.find(54); // UNIT_FIELD_LEVEL
if (levelIt != block.fields.end()) unit->setLevel(levelIt->second);
}
break;
}
@ -613,6 +804,22 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
for (const auto& field : block.fields) {
entity->setField(field.first, field.second);
}
// Update cached health/mana/power values (Phase 2)
if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<Unit>(entity);
auto hpIt = block.fields.find(24);
if (hpIt != block.fields.end()) unit->setHealth(hpIt->second);
auto maxHpIt = block.fields.find(32);
if (maxHpIt != block.fields.end()) unit->setMaxHealth(maxHpIt->second);
auto powerIt = block.fields.find(25);
if (powerIt != block.fields.end()) unit->setPower(powerIt->second);
auto maxPowerIt = block.fields.find(33);
if (maxPowerIt != block.fields.end()) unit->setMaxPower(maxPowerIt->second);
auto levelIt = block.fields.find(54);
if (levelIt != block.fields.end()) unit->setLevel(levelIt->second);
}
LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec);
} else {
LOG_WARNING("VALUES update for unknown entity: 0x", std::hex, block.guid, std::dec);
@ -737,6 +944,13 @@ void GameHandler::handleMessageChat(network::Packet& packet) {
void GameHandler::setTarget(uint64_t guid) {
if (guid == targetGuid) return;
targetGuid = guid;
// Inform server of target selection (Phase 1)
if (state == WorldState::IN_WORLD && socket) {
auto packet = SetSelectionPacket::build(guid);
socket->send(packet);
}
if (guid != 0) {
LOG_INFO("Target set: 0x", std::hex, guid, std::dec);
}
@ -815,6 +1029,539 @@ std::vector<MessageChatData> GameHandler::getChatHistory(size_t maxMessages) con
);
}
// ============================================================
// Phase 1: Name Queries
// ============================================================
void GameHandler::queryPlayerName(uint64_t guid) {
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
if (state != WorldState::IN_WORLD || !socket) return;
pendingNameQueries.insert(guid);
auto packet = NameQueryPacket::build(guid);
socket->send(packet);
}
void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) {
if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return;
if (state != WorldState::IN_WORLD || !socket) return;
pendingCreatureQueries.insert(entry);
auto packet = CreatureQueryPacket::build(entry, guid);
socket->send(packet);
}
std::string GameHandler::getCachedPlayerName(uint64_t guid) const {
auto it = playerNameCache.find(guid);
return (it != playerNameCache.end()) ? it->second : "";
}
std::string GameHandler::getCachedCreatureName(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.name : "";
}
void GameHandler::handleNameQueryResponse(network::Packet& packet) {
NameQueryResponseData data;
if (!NameQueryResponseParser::parse(packet, data)) return;
pendingNameQueries.erase(data.guid);
if (data.isValid()) {
playerNameCache[data.guid] = data.name;
// Update entity name
auto entity = entityManager.getEntity(data.guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(entity);
player->setName(data.name);
}
}
}
void GameHandler::handleCreatureQueryResponse(network::Packet& packet) {
CreatureQueryResponseData data;
if (!CreatureQueryResponseParser::parse(packet, data)) return;
pendingCreatureQueries.erase(data.entry);
if (data.isValid()) {
creatureInfoCache[data.entry] = data;
// Update all unit entities with this entry
for (auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(entity);
if (unit->getEntry() == data.entry) {
unit->setName(data.name);
}
}
}
}
}
// ============================================================
// Phase 2: Combat
// ============================================================
void GameHandler::startAutoAttack(uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
autoAttacking = true;
autoAttackTarget = targetGuid;
auto packet = AttackSwingPacket::build(targetGuid);
socket->send(packet);
LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::stopAutoAttack() {
if (!autoAttacking) return;
autoAttacking = false;
autoAttackTarget = 0;
if (state == WorldState::IN_WORLD && socket) {
auto packet = AttackStopPacket::build();
socket->send(packet);
}
LOG_INFO("Stopping auto-attack");
}
void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) {
CombatTextEntry entry;
entry.type = type;
entry.amount = amount;
entry.spellId = spellId;
entry.age = 0.0f;
entry.isPlayerSource = isPlayerSource;
combatText.push_back(entry);
}
void GameHandler::updateCombatText(float deltaTime) {
for (auto& entry : combatText) {
entry.age += deltaTime;
}
combatText.erase(
std::remove_if(combatText.begin(), combatText.end(),
[](const CombatTextEntry& e) { return e.isExpired(); }),
combatText.end());
}
void GameHandler::handleAttackStart(network::Packet& packet) {
AttackStartData data;
if (!AttackStartParser::parse(packet, data)) return;
if (data.attackerGuid == playerGuid) {
autoAttacking = true;
autoAttackTarget = data.victimGuid;
}
}
void GameHandler::handleAttackStop(network::Packet& packet) {
AttackStopData data;
if (!AttackStopParser::parse(packet, data)) return;
if (data.attackerGuid == playerGuid) {
autoAttacking = false;
autoAttackTarget = 0;
}
}
void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
AttackerStateUpdateData data;
if (!AttackerStateUpdateParser::parse(packet, data)) return;
bool isPlayerAttacker = (data.attackerGuid == playerGuid);
bool isPlayerTarget = (data.targetGuid == playerGuid);
if (data.isMiss()) {
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker);
} else if (data.victimState == 1) {
addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker);
} else if (data.victimState == 2) {
addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker);
} else {
auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE;
addCombatText(type, data.totalDamage, 0, isPlayerAttacker);
}
(void)isPlayerTarget; // Used for future incoming damage display
}
void GameHandler::handleSpellDamageLog(network::Packet& packet) {
SpellDamageLogData data;
if (!SpellDamageLogParser::parse(packet, data)) return;
bool isPlayerSource = (data.attackerGuid == playerGuid);
auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE;
addCombatText(type, static_cast<int32_t>(data.damage), data.spellId, isPlayerSource);
}
void GameHandler::handleSpellHealLog(network::Packet& packet) {
SpellHealLogData data;
if (!SpellHealLogParser::parse(packet, data)) return;
bool isPlayerSource = (data.casterGuid == playerGuid);
auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL;
addCombatText(type, static_cast<int32_t>(data.heal), data.spellId, isPlayerSource);
}
// ============================================================
// Phase 3: Spells
// ============================================================
void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
if (casting) return; // Already casting
uint64_t target = targetGuid != 0 ? targetGuid : targetGuid;
auto packet = CastSpellPacket::build(spellId, target, ++castCount);
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
}
void GameHandler::cancelCast() {
if (!casting) return;
if (state == WorldState::IN_WORLD && socket) {
auto packet = CancelCastPacket::build(currentCastSpellId);
socket->send(packet);
}
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
void GameHandler::cancelAura(uint32_t spellId) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = CancelAuraPacket::build(spellId);
socket->send(packet);
}
void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) {
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
actionBar[slot].type = type;
actionBar[slot].id = id;
}
float GameHandler::getSpellCooldown(uint32_t spellId) const {
auto it = spellCooldowns.find(spellId);
return (it != spellCooldowns.end()) ? it->second : 0.0f;
}
void GameHandler::handleInitialSpells(network::Packet& packet) {
InitialSpellsData data;
if (!InitialSpellsParser::parse(packet, data)) return;
knownSpells = data.spellIds;
// Set initial cooldowns
for (const auto& cd : data.cooldowns) {
if (cd.cooldownMs > 0) {
spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f;
}
}
// Auto-populate action bar with first 12 spells
for (int i = 0; i < ACTION_BAR_SLOTS && i < static_cast<int>(knownSpells.size()); ++i) {
actionBar[i].type = ActionBarSlot::SPELL;
actionBar[i].id = knownSpells[i];
}
LOG_INFO("Learned ", knownSpells.size(), " spells");
}
void GameHandler::handleCastFailed(network::Packet& packet) {
CastFailedData data;
if (!CastFailedParser::parse(packet, data)) return;
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Add system message about failed cast
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
addLocalChatMessage(msg);
}
void GameHandler::handleSpellStart(network::Packet& packet) {
SpellStartData data;
if (!SpellStartParser::parse(packet, data)) return;
// If this is the player's own cast, start cast bar
if (data.casterUnit == playerGuid && data.castTime > 0) {
casting = true;
currentCastSpellId = data.spellId;
castTimeTotal = data.castTime / 1000.0f;
castTimeRemaining = castTimeTotal;
}
}
void GameHandler::handleSpellGo(network::Packet& packet) {
SpellGoData data;
if (!SpellGoParser::parse(packet, data)) return;
// Cast completed
if (data.casterUnit == playerGuid) {
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
}
void GameHandler::handleSpellCooldown(network::Packet& packet) {
SpellCooldownData data;
if (!SpellCooldownParser::parse(packet, data)) return;
for (const auto& [spellId, cooldownMs] : data.cooldowns) {
float seconds = cooldownMs / 1000.0f;
spellCooldowns[spellId] = seconds;
// Update action bar cooldowns
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownTotal = seconds;
slot.cooldownRemaining = seconds;
}
}
}
}
void GameHandler::handleCooldownEvent(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
// Cooldown finished
spellCooldowns.erase(spellId);
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = 0.0f;
}
}
}
void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
AuraUpdateData data;
if (!AuraUpdateParser::parse(packet, data, isAll)) return;
// Determine which aura list to update
std::vector<AuraSlot>* auraList = nullptr;
if (data.guid == playerGuid) {
auraList = &playerAuras;
} else if (data.guid == targetGuid) {
auraList = &targetAuras;
}
if (auraList) {
for (const auto& [slot, aura] : data.updates) {
// Ensure vector is large enough
while (auraList->size() <= slot) {
auraList->push_back(AuraSlot{});
}
(*auraList)[slot] = aura;
}
}
}
void GameHandler::handleLearnedSpell(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
knownSpells.push_back(spellId);
LOG_INFO("Learned spell: ", spellId);
}
void GameHandler::handleRemovedSpell(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
knownSpells.erase(
std::remove(knownSpells.begin(), knownSpells.end(), spellId),
knownSpells.end());
LOG_INFO("Removed spell: ", spellId);
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
void GameHandler::inviteToGroup(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GroupInvitePacket::build(playerName);
socket->send(packet);
LOG_INFO("Inviting ", playerName, " to group");
}
void GameHandler::acceptGroupInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGroupInvite = false;
auto packet = GroupAcceptPacket::build();
socket->send(packet);
LOG_INFO("Accepted group invite");
}
void GameHandler::declineGroupInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGroupInvite = false;
auto packet = GroupDeclinePacket::build();
socket->send(packet);
LOG_INFO("Declined group invite");
}
void GameHandler::leaveGroup() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GroupDisbandPacket::build();
socket->send(packet);
partyData = GroupListData{};
LOG_INFO("Left group");
}
void GameHandler::handleGroupInvite(network::Packet& packet) {
GroupInviteResponseData data;
if (!GroupInviteResponseParser::parse(packet, data)) return;
pendingGroupInvite = true;
pendingInviterName = data.inviterName;
LOG_INFO("Group invite from: ", data.inviterName);
}
void GameHandler::handleGroupDecline(network::Packet& packet) {
GroupDeclineData data;
if (!GroupDeclineResponseParser::parse(packet, data)) return;
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = data.playerName + " has declined your group invitation.";
addLocalChatMessage(msg);
}
void GameHandler::handleGroupList(network::Packet& packet) {
if (!GroupListParser::parse(packet, partyData)) return;
if (partyData.isEmpty()) {
LOG_INFO("No longer in a group");
} else {
LOG_INFO("In group with ", partyData.memberCount, " members");
}
}
void GameHandler::handleGroupUninvite(network::Packet& packet) {
(void)packet;
partyData = GroupListData{};
LOG_INFO("Removed from group");
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "You have been removed from the group.";
addLocalChatMessage(msg);
}
void GameHandler::handlePartyCommandResult(network::Packet& packet) {
PartyCommandResultData data;
if (!PartyCommandResultParser::parse(packet, data)) return;
if (data.result != PartyResult::OK) {
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "Party command failed (error " + std::to_string(static_cast<uint32_t>(data.result)) + ")";
if (!data.name.empty()) msg.message += " for " + data.name;
addLocalChatMessage(msg);
}
}
// ============================================================
// Phase 5: Loot, Gossip, Vendor
// ============================================================
void GameHandler::lootTarget(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = LootPacket::build(guid);
socket->send(packet);
}
void GameHandler::lootItem(uint8_t slotIndex) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = AutostoreLootItemPacket::build(slotIndex);
socket->send(packet);
}
void GameHandler::closeLoot() {
if (!lootWindowOpen) return;
lootWindowOpen = false;
if (state == WorldState::IN_WORLD && socket) {
auto packet = LootReleasePacket::build(currentLoot.lootGuid);
socket->send(packet);
}
currentLoot = LootResponseData{};
}
void GameHandler::interactWithNpc(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GossipHelloPacket::build(guid);
socket->send(packet);
}
void GameHandler::selectGossipOption(uint32_t optionId) {
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
auto packet = GossipSelectOptionPacket::build(currentGossip.npcGuid, optionId);
socket->send(packet);
}
void GameHandler::closeGossip() {
gossipWindowOpen = false;
currentGossip = GossipMessageData{};
}
void GameHandler::openVendor(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = ListInventoryPacket::build(npcGuid);
socket->send(packet);
}
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count);
socket->send(packet);
}
void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = SellItemPacket::build(vendorGuid, itemGuid, count);
socket->send(packet);
}
void GameHandler::handleLootResponse(network::Packet& packet) {
if (!LootResponseParser::parse(packet, currentLoot)) return;
lootWindowOpen = true;
}
void GameHandler::handleLootReleaseResponse(network::Packet& packet) {
(void)packet;
lootWindowOpen = false;
currentLoot = LootResponseData{};
}
void GameHandler::handleLootRemoved(network::Packet& packet) {
uint8_t slotIndex = packet.readUInt8();
for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) {
if (it->slotIndex == slotIndex) {
currentLoot.items.erase(it);
break;
}
}
}
void GameHandler::handleGossipMessage(network::Packet& packet) {
if (!GossipMessageParser::parse(packet, currentGossip)) return;
gossipWindowOpen = true;
vendorWindowOpen = false; // Close vendor if gossip opens
}
void GameHandler::handleGossipComplete(network::Packet& packet) {
(void)packet;
gossipWindowOpen = false;
currentGossip = GossipMessageData{};
}
void GameHandler::handleListInventory(network::Packet& packet) {
if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
vendorWindowOpen = true;
gossipWindowOpen = false; // Close gossip if vendor opens
}
uint32_t GameHandler::generateClientSeed() {
// Generate cryptographically random seed
std::random_device rd;

View file

@ -861,5 +861,642 @@ const char* getChatTypeString(ChatType type) {
}
}
// ============================================================
// Phase 1: Foundation — Targeting, Name Queries
// ============================================================
network::Packet SetSelectionPacket::build(uint64_t targetGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SET_SELECTION));
packet.writeUInt64(targetGuid);
LOG_DEBUG("Built CMSG_SET_SELECTION: target=0x", std::hex, targetGuid, std::dec);
return packet;
}
network::Packet SetActiveMoverPacket::build(uint64_t guid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SET_ACTIVE_MOVER));
packet.writeUInt64(guid);
LOG_DEBUG("Built CMSG_SET_ACTIVE_MOVER: guid=0x", std::hex, guid, std::dec);
return packet;
}
network::Packet NameQueryPacket::build(uint64_t playerGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_NAME_QUERY));
packet.writeUInt64(playerGuid);
LOG_DEBUG("Built CMSG_NAME_QUERY: guid=0x", std::hex, playerGuid, std::dec);
return packet;
}
bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseData& data) {
// 3.3.5a: packedGuid, uint8 found
// If found==0: CString name, CString realmName, uint8 race, uint8 gender, uint8 classId
data.guid = UpdateObjectParser::readPackedGuid(packet);
data.found = packet.readUInt8();
if (data.found != 0) {
LOG_DEBUG("Name query: player not found for GUID 0x", std::hex, data.guid, std::dec);
return true; // Valid response, just not found
}
data.name = packet.readString();
data.realmName = packet.readString();
data.race = packet.readUInt8();
data.gender = packet.readUInt8();
data.classId = packet.readUInt8();
LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race,
" class=", (int)data.classId, ")");
return true;
}
network::Packet CreatureQueryPacket::build(uint32_t entry, uint64_t guid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CREATURE_QUERY));
packet.writeUInt32(entry);
packet.writeUInt64(guid);
LOG_DEBUG("Built CMSG_CREATURE_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec);
return packet;
}
bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryResponseData& data) {
data.entry = packet.readUInt32();
// High bit set means creature not found
if (data.entry & 0x80000000) {
data.entry &= ~0x80000000;
LOG_DEBUG("Creature query: entry ", data.entry, " not found");
data.name = "";
return true;
}
// 4 name strings (only first is usually populated)
data.name = packet.readString();
packet.readString(); // name2
packet.readString(); // name3
packet.readString(); // name4
data.subName = packet.readString();
data.iconName = packet.readString();
data.typeFlags = packet.readUInt32();
data.creatureType = packet.readUInt32();
data.family = packet.readUInt32();
data.rank = packet.readUInt32();
// Skip remaining fields (kill credits, display IDs, modifiers, quest items, etc.)
// We've got what we need for display purposes
LOG_INFO("Creature query response: ", data.name, " (type=", data.creatureType,
" rank=", data.rank, ")");
return true;
}
// ============================================================
// Phase 2: Combat Core
// ============================================================
network::Packet AttackSwingPacket::build(uint64_t targetGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_ATTACKSWING));
packet.writeUInt64(targetGuid);
LOG_DEBUG("Built CMSG_ATTACKSWING: target=0x", std::hex, targetGuid, std::dec);
return packet;
}
network::Packet AttackStopPacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_ATTACKSTOP));
LOG_DEBUG("Built CMSG_ATTACKSTOP");
return packet;
}
bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) {
if (packet.getSize() < 16) return false;
data.attackerGuid = packet.readUInt64();
data.victimGuid = packet.readUInt64();
LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid,
" -> 0x", data.victimGuid, std::dec);
return true;
}
bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) {
data.attackerGuid = UpdateObjectParser::readPackedGuid(packet);
data.victimGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getReadPos() < packet.getSize()) {
data.unknown = packet.readUInt32();
}
LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec);
return true;
}
bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) {
data.hitInfo = packet.readUInt32();
data.attackerGuid = UpdateObjectParser::readPackedGuid(packet);
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
for (uint8_t i = 0; i < data.subDamageCount; ++i) {
SubDamage sub;
sub.schoolMask = packet.readUInt32();
sub.damage = packet.readFloat();
sub.intDamage = packet.readUInt32();
sub.absorbed = packet.readUInt32();
sub.resisted = packet.readUInt32();
data.subDamages.push_back(sub);
}
data.victimState = packet.readUInt32();
data.overkill = static_cast<int32_t>(packet.readUInt32());
// Read blocked amount
if (packet.getReadPos() < packet.getSize()) {
data.blocked = packet.readUInt32();
}
LOG_INFO("Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) {
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
data.attackerGuid = UpdateObjectParser::readPackedGuid(packet);
data.spellId = packet.readUInt32();
data.damage = packet.readUInt32();
data.overkill = packet.readUInt32();
data.schoolMask = packet.readUInt8();
data.absorbed = packet.readUInt32();
data.resisted = packet.readUInt32();
// Skip remaining fields
uint8_t periodicLog = packet.readUInt8();
(void)periodicLog;
packet.readUInt8(); // unused
packet.readUInt32(); // blocked
uint32_t flags = packet.readUInt32();
(void)flags;
// Check crit flag
data.isCrit = (flags & 0x02) != 0;
LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) {
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
data.casterGuid = UpdateObjectParser::readPackedGuid(packet);
data.spellId = packet.readUInt32();
data.heal = packet.readUInt32();
data.overheal = packet.readUInt32();
data.absorbed = packet.readUInt32();
uint8_t critFlag = packet.readUInt8();
data.isCrit = (critFlag != 0);
LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}
// ============================================================
// Phase 3: Spells, Action Bar, Auras
// ============================================================
bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data) {
data.talentSpec = packet.readUInt8();
uint16_t spellCount = packet.readUInt16();
data.spellIds.reserve(spellCount);
for (uint16_t i = 0; i < spellCount; ++i) {
uint32_t spellId = packet.readUInt32();
packet.readUInt16(); // unknown (always 0)
if (spellId != 0) {
data.spellIds.push_back(spellId);
}
}
uint16_t cooldownCount = packet.readUInt16();
data.cooldowns.reserve(cooldownCount);
for (uint16_t i = 0; i < cooldownCount; ++i) {
SpellCooldownEntry entry;
entry.spellId = packet.readUInt32();
entry.itemId = packet.readUInt16();
entry.categoryId = packet.readUInt16();
entry.cooldownMs = packet.readUInt32();
entry.categoryCooldownMs = packet.readUInt32();
data.cooldowns.push_back(entry);
}
LOG_INFO("Initial spells: ", data.spellIds.size(), " spells, ",
data.cooldowns.size(), " cooldowns");
return true;
}
network::Packet CastSpellPacket::build(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CAST_SPELL));
packet.writeUInt8(castCount);
packet.writeUInt32(spellId);
packet.writeUInt8(0x00); // castFlags = 0 for normal cast
// SpellCastTargets
if (targetGuid != 0) {
packet.writeUInt32(0x02); // TARGET_FLAG_UNIT
// Write packed GUID
uint8_t mask = 0;
uint8_t bytes[8];
int byteCount = 0;
uint64_t g = targetGuid;
for (int i = 0; i < 8; ++i) {
uint8_t b = g & 0xFF;
if (b != 0) {
mask |= (1 << i);
bytes[byteCount++] = b;
}
g >>= 8;
}
packet.writeUInt8(mask);
for (int i = 0; i < byteCount; ++i) {
packet.writeUInt8(bytes[i]);
}
} else {
packet.writeUInt32(0x00); // TARGET_FLAG_SELF
}
LOG_DEBUG("Built CMSG_CAST_SPELL: spell=", spellId, " target=0x",
std::hex, targetGuid, std::dec);
return packet;
}
network::Packet CancelCastPacket::build(uint32_t spellId) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CANCEL_CAST));
packet.writeUInt32(0); // sequence
packet.writeUInt32(spellId);
return packet;
}
network::Packet CancelAuraPacket::build(uint32_t spellId) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CANCEL_AURA));
packet.writeUInt32(spellId);
return packet;
}
bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) {
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.result = packet.readUInt8();
LOG_INFO("Cast failed: spell=", data.spellId, " result=", (int)data.result);
return true;
}
bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) {
data.casterGuid = UpdateObjectParser::readPackedGuid(packet);
data.casterUnit = UpdateObjectParser::readPackedGuid(packet);
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt32();
data.castTime = packet.readUInt32();
// Read target flags and target (simplified)
if (packet.getReadPos() < packet.getSize()) {
uint32_t targetFlags = packet.readUInt32();
if (targetFlags & 0x02) { // TARGET_FLAG_UNIT
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
}
}
LOG_INFO("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
return true;
}
bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
data.casterGuid = UpdateObjectParser::readPackedGuid(packet);
data.casterUnit = UpdateObjectParser::readPackedGuid(packet);
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt32();
// Timestamp in 3.3.5a
packet.readUInt32();
data.hitCount = packet.readUInt8();
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount; ++i) {
data.hitTargets.push_back(packet.readUInt64());
}
data.missCount = packet.readUInt8();
// Skip miss details for now
LOG_INFO("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;
}
bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) {
data.guid = UpdateObjectParser::readPackedGuid(packet);
while (packet.getReadPos() < packet.getSize()) {
uint8_t slot = packet.readUInt8();
uint32_t spellId = packet.readUInt32();
AuraSlot aura;
if (spellId != 0) {
aura.spellId = spellId;
aura.flags = packet.readUInt8();
aura.level = packet.readUInt8();
aura.charges = packet.readUInt8();
if (!(aura.flags & 0x08)) { // NOT_CASTER flag
aura.casterGuid = UpdateObjectParser::readPackedGuid(packet);
}
if (aura.flags & 0x20) { // DURATION
aura.maxDurationMs = static_cast<int32_t>(packet.readUInt32());
aura.durationMs = static_cast<int32_t>(packet.readUInt32());
}
if (aura.flags & 0x40) { // EFFECT_AMOUNTS - skip
// 3 effect amounts
for (int i = 0; i < 3; ++i) {
if (packet.getReadPos() < packet.getSize()) {
packet.readUInt32();
}
}
}
}
data.updates.push_back({slot, aura});
// For single update, only one entry
if (!isAll) break;
}
LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec,
": ", data.updates.size(), " slots");
return true;
}
bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) {
data.guid = packet.readUInt64();
data.flags = packet.readUInt8();
while (packet.getReadPos() + 8 <= packet.getSize()) {
uint32_t spellId = packet.readUInt32();
uint32_t cooldownMs = packet.readUInt32();
data.cooldowns.push_back({spellId, cooldownMs});
}
LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries");
return true;
}
// ============================================================
// Phase 4: Group/Party System
// ============================================================
network::Packet GroupInvitePacket::build(const std::string& playerName) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GROUP_INVITE));
packet.writeString(playerName);
packet.writeUInt32(0); // unused
LOG_DEBUG("Built CMSG_GROUP_INVITE: ", playerName);
return packet;
}
bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) {
data.canAccept = packet.readUInt8();
data.inviterName = packet.readString();
LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", (int)data.canAccept, ")");
return true;
}
network::Packet GroupAcceptPacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GROUP_ACCEPT));
packet.writeUInt32(0); // unused in 3.3.5a
return packet;
}
network::Packet GroupDeclinePacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GROUP_DECLINE));
return packet;
}
network::Packet GroupDisbandPacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GROUP_DISBAND));
return packet;
}
bool GroupListParser::parse(network::Packet& packet, GroupListData& data) {
data.groupType = packet.readUInt8();
data.subGroup = packet.readUInt8();
data.flags = packet.readUInt8();
data.roles = packet.readUInt8();
// Skip LFG data if present
if (data.groupType & 0x04) {
packet.readUInt8(); // lfg state
packet.readUInt32(); // lfg entry
packet.readUInt8(); // lfg flags (3.3.5a may not have this)
}
packet.readUInt64(); // group GUID
packet.readUInt32(); // counter
data.memberCount = packet.readUInt32();
data.members.reserve(data.memberCount);
for (uint32_t i = 0; i < data.memberCount; ++i) {
GroupMember member;
member.name = packet.readString();
member.guid = packet.readUInt64();
member.isOnline = packet.readUInt8();
member.subGroup = packet.readUInt8();
member.flags = packet.readUInt8();
member.roles = packet.readUInt8();
data.members.push_back(member);
}
data.leaderGuid = packet.readUInt64();
if (data.memberCount > 0 && packet.getReadPos() < packet.getSize()) {
data.lootMethod = packet.readUInt8();
data.looterGuid = packet.readUInt64();
data.lootThreshold = packet.readUInt8();
data.difficultyId = packet.readUInt8();
data.raidDifficultyId = packet.readUInt8();
if (packet.getReadPos() < packet.getSize()) {
packet.readUInt8(); // unknown byte
}
}
LOG_INFO("Group list: ", data.memberCount, " members, leader=0x",
std::hex, data.leaderGuid, std::dec);
return true;
}
bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) {
data.command = static_cast<PartyCommand>(packet.readUInt32());
data.name = packet.readString();
data.result = static_cast<PartyResult>(packet.readUInt32());
LOG_INFO("Party command result: ", (int)data.result);
return true;
}
bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) {
data.playerName = packet.readString();
LOG_INFO("Group decline from: ", data.playerName);
return true;
}
// ============================================================
// Phase 5: Loot System
// ============================================================
network::Packet LootPacket::build(uint64_t targetGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LOOT));
packet.writeUInt64(targetGuid);
LOG_DEBUG("Built CMSG_LOOT: target=0x", std::hex, targetGuid, std::dec);
return packet;
}
network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_AUTOSTORE_LOOT_ITEM));
packet.writeUInt8(slotIndex);
return packet;
}
network::Packet LootReleasePacket::build(uint64_t lootGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LOOT_RELEASE));
packet.writeUInt64(lootGuid);
return packet;
}
bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) {
data.lootGuid = packet.readUInt64();
data.lootType = packet.readUInt8();
data.gold = packet.readUInt32();
uint8_t itemCount = packet.readUInt8();
data.items.reserve(itemCount);
for (uint8_t i = 0; i < itemCount; ++i) {
LootItem item;
item.slotIndex = packet.readUInt8();
item.itemId = packet.readUInt32();
item.count = packet.readUInt32();
item.displayInfoId = packet.readUInt32();
item.randomSuffix = packet.readUInt32();
item.randomPropertyId = packet.readUInt32();
item.lootSlotType = packet.readUInt8();
data.items.push_back(item);
}
LOG_INFO("Loot response: ", (int)itemCount, " items, ", data.gold, " copper");
return true;
}
// ============================================================
// Phase 5: NPC Gossip
// ============================================================
network::Packet GossipHelloPacket::build(uint64_t npcGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GOSSIP_HELLO));
packet.writeUInt64(npcGuid);
return packet;
}
network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t optionId, const std::string& code) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_GOSSIP_SELECT_OPTION));
packet.writeUInt64(npcGuid);
packet.writeUInt32(optionId);
if (!code.empty()) {
packet.writeString(code);
}
return packet;
}
bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) {
data.npcGuid = packet.readUInt64();
data.menuId = packet.readUInt32();
data.titleTextId = packet.readUInt32();
uint32_t optionCount = packet.readUInt32();
data.options.reserve(optionCount);
for (uint32_t i = 0; i < optionCount; ++i) {
GossipOption opt;
opt.id = packet.readUInt32();
opt.icon = packet.readUInt8();
opt.isCoded = (packet.readUInt8() != 0);
opt.boxMoney = packet.readUInt32();
opt.text = packet.readString();
opt.boxText = packet.readString();
data.options.push_back(opt);
}
uint32_t questCount = packet.readUInt32();
data.quests.reserve(questCount);
for (uint32_t i = 0; i < questCount; ++i) {
GossipQuestItem quest;
quest.questId = packet.readUInt32();
quest.questIcon = packet.readUInt32();
quest.questLevel = static_cast<int32_t>(packet.readUInt32());
quest.questFlags = packet.readUInt32();
quest.isRepeatable = packet.readUInt8();
quest.title = packet.readString();
data.quests.push_back(quest);
}
LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests");
return true;
}
// ============================================================
// Phase 5: Vendor
// ============================================================
network::Packet ListInventoryPacket::build(uint64_t npcGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LIST_INVENTORY));
packet.writeUInt64(npcGuid);
return packet;
}
network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_BUY_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt32(itemId);
packet.writeUInt32(slot);
packet.writeUInt8(count);
return packet;
}
network::Packet SellItemPacket::build(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SELL_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt64(itemGuid);
packet.writeUInt8(count);
return packet;
}
bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data) {
data.vendorGuid = packet.readUInt64();
uint8_t itemCount = packet.readUInt8();
if (itemCount == 0) {
LOG_INFO("Vendor has nothing for sale");
return true;
}
data.items.reserve(itemCount);
for (uint8_t i = 0; i < itemCount; ++i) {
VendorItem item;
item.slot = packet.readUInt32();
item.itemId = packet.readUInt32();
item.displayInfoId = packet.readUInt32();
item.maxCount = static_cast<int32_t>(packet.readUInt32());
item.buyPrice = packet.readUInt32();
item.durability = packet.readUInt32();
item.stackCount = packet.readUInt32();
item.extendedCost = packet.readUInt32();
data.items.push_back(item);
}
LOG_INFO("Vendor inventory: ", (int)itemCount, " items");
return true;
}
} // namespace game
} // namespace wowee

View file

@ -54,21 +54,6 @@ void GameScreen::render(game::GameHandler& gameHandler) {
// Process targeting input before UI windows
processTargetInput(gameHandler);
// Main menu bar
if (ImGui::BeginMainMenuBar()) {
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("Player Info", nullptr, &showPlayerInfo);
ImGui::MenuItem("Entity List", nullptr, &showEntityWindow);
ImGui::MenuItem("Chat", nullptr, &showChatWindow);
bool invOpen = inventoryScreen.isOpen();
if (ImGui::MenuItem("Inventory", "B", &invOpen)) {
inventoryScreen.setOpen(invOpen);
}
ImGui::EndMenu();
}
ImGui::EndMainMenuBar();
}
// Player unit frame (top-left)
renderPlayerFrame(gameHandler);
@ -90,6 +75,17 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderChatWindow(gameHandler);
}
// ---- New UI elements ----
renderActionBar(gameHandler);
renderCastBar(gameHandler);
renderCombatText(gameHandler);
renderPartyFrames(gameHandler);
renderGroupInvitePopup(gameHandler);
renderBuffBar(gameHandler);
renderLootWindow(gameHandler);
renderGossipWindow(gameHandler);
renderVendorWindow(gameHandler);
// Inventory (B key toggle handled inside)
inventoryScreen.render(gameHandler.getInventory());
@ -346,7 +342,40 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
}
if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
gameHandler.clearTarget();
if (gameHandler.isCasting()) {
gameHandler.cancelCast();
} else if (gameHandler.isLootWindowOpen()) {
gameHandler.closeLoot();
} else if (gameHandler.isGossipWindowOpen()) {
gameHandler.closeGossip();
} else {
gameHandler.clearTarget();
}
}
// Auto-attack (T key)
if (input.isKeyJustPressed(SDL_SCANCODE_T)) {
if (gameHandler.hasTarget() && !gameHandler.isAutoAttacking()) {
gameHandler.startAutoAttack(gameHandler.getTargetGuid());
} else if (gameHandler.isAutoAttacking()) {
gameHandler.stopAutoAttack();
}
}
// Action bar keys (1-9, 0, -, =)
static const SDL_Scancode actionBarKeys[] = {
SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4,
SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8,
SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS
};
for (int i = 0; i < 12; ++i) {
if (input.isKeyJustPressed(actionBarKeys[i])) {
const auto& bar = gameHandler.getActionBar();
if (bar[i].type == game::ActionBarSlot::SPELL && bar[i].isReady()) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(bar[i].id, target);
}
}
}
}
@ -389,6 +418,27 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
// Don't clear on miss — left-click is also used for camera orbit
}
}
// Right-click on target for NPC interaction / loot / auto-attack
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT)) {
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
if (target->getType() == game::ObjectType::UNIT) {
// Check if unit is dead (health == 0) → loot, otherwise interact/attack
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
gameHandler.lootTarget(target->getGuid());
} else {
// Try NPC interaction first (gossip), fall back to attack
gameHandler.interactWithNpc(target->getGuid());
}
} else if (target->getType() == game::ObjectType::PLAYER) {
// Right-click another player could start attack in PvP context
}
}
}
}
}
void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
@ -426,6 +476,16 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", playerLevel);
// Try to get real HP/mana from the player entity
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0) {
playerHp = unit->getHealth();
playerMaxHp = unit->getMaxHealth();
}
}
// Health bar
float pct = static_cast<float>(playerHp) / static_cast<float>(playerMaxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f));
@ -433,6 +493,29 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Mana/Power bar (Phase 2)
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
uint32_t power = unit->getPower();
uint32_t maxPower = unit->getMaxPower();
if (maxPower > 0) {
float mpPct = static_cast<float>(power) / static_cast<float>(maxPower);
// Color by power type
ImVec4 powerColor;
switch (unit->getPowerType()) {
case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue)
case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red)
case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow)
default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpOverlay);
ImGui::PopStyleColor();
}
}
}
ImGui::End();
@ -500,6 +583,17 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Target mana bar
uint32_t targetPower = unit->getPower();
uint32_t targetMaxPower = unit->getMaxPower();
if (targetMaxPower > 0) {
float mpPct = static_cast<float>(targetPower) / static_cast<float>(targetMaxPower);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.2f, 0.9f, 1.0f));
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpOverlay);
ImGui::PopStyleColor();
}
} else {
ImGui::TextDisabled("No health data");
}
@ -561,6 +655,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
chatInputBuffer[0] = '\0';
return;
}
// /invite command (Phase 4)
if (command.size() > 7 && command.substr(0, 7) == "invite ") {
std::string targetName = input.substr(8);
gameHandler.inviteToGroup(targetName);
chatInputBuffer[0] = '\0';
return;
}
// Not a recognized emote — fall through and send as normal chat
}
@ -858,4 +960,548 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) {
}
}
// ============================================================
// Action Bar (Phase 3)
// ============================================================
void GameScreen::renderActionBar(game::GameHandler& gameHandler) {
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float slotSize = 48.0f;
float spacing = 4.0f;
float padding = 8.0f;
float barW = 12 * slotSize + 11 * spacing + padding * 2;
float barH = slotSize + 24.0f;
float barX = (screenW - barW) / 2.0f;
float barY = screenH - barH;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f));
if (ImGui::Begin("##ActionBar", nullptr, flags)) {
const auto& bar = gameHandler.getActionBar();
static const char* keyLabels[] = {"1","2","3","4","5","6","7","8","9","0","-","="};
for (int i = 0; i < 12; ++i) {
if (i > 0) ImGui::SameLine(0, spacing);
ImGui::BeginGroup();
ImGui::PushID(i);
const auto& slot = bar[i];
bool onCooldown = !slot.isReady();
if (onCooldown) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f));
} else if (slot.isEmpty()) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
} else {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f));
}
char label[32];
if (slot.type == game::ActionBarSlot::SPELL) {
snprintf(label, sizeof(label), "S%u", slot.id);
} else {
snprintf(label, sizeof(label), "--");
}
if (ImGui::Button(label, ImVec2(slotSize, slotSize))) {
if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(slot.id, target);
}
}
ImGui::PopStyleColor();
// Cooldown overlay text
if (onCooldown) {
char cdText[16];
snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() - slotSize / 2 - 8);
ImGui::TextColored(ImVec4(1, 1, 0, 1), "%s", cdText);
}
// Key label below
ImGui::TextDisabled("%s", keyLabels[i]);
ImGui::PopID();
ImGui::EndGroup();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Cast Bar (Phase 3)
// ============================================================
void GameScreen::renderCastBar(game::GameHandler& gameHandler) {
if (!gameHandler.isCasting()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float barW = 300.0f;
float barX = (screenW - barW) / 2.0f;
float barY = screenH - 120.0f;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f));
if (ImGui::Begin("##CastBar", nullptr, flags)) {
float progress = gameHandler.getCastProgress();
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.6f, 0.2f, 1.0f));
char overlay[64];
snprintf(overlay, sizeof(overlay), "Spell %u (%.1fs)",
gameHandler.getCurrentCastSpellId(), gameHandler.getCastTimeRemaining());
ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay);
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Floating Combat Text (Phase 2)
// ============================================================
void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
const auto& entries = gameHandler.getCombatText();
if (entries.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Render combat text entries overlaid on screen
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, 400));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
if (ImGui::Begin("##CombatText", nullptr, flags)) {
float centerX = screenW / 2.0f;
int index = 0;
for (const auto& entry : entries) {
float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME);
float yOffset = 200.0f - entry.age * 60.0f;
ImVec4 color;
char text[64];
switch (entry.type) {
case game::CombatTextEntry::MELEE_DAMAGE:
case game::CombatTextEntry::SPELL_DAMAGE:
snprintf(text, sizeof(text), "-%d", entry.amount);
color = entry.isPlayerSource ?
ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow
ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red
break;
case game::CombatTextEntry::CRIT_DAMAGE:
snprintf(text, sizeof(text), "-%d!", entry.amount);
color = ImVec4(1.0f, 0.5f, 0.0f, alpha); // Orange for crit
break;
case game::CombatTextEntry::HEAL:
snprintf(text, sizeof(text), "+%d", entry.amount);
color = ImVec4(0.3f, 1.0f, 0.3f, alpha);
break;
case game::CombatTextEntry::CRIT_HEAL:
snprintf(text, sizeof(text), "+%d!", entry.amount);
color = ImVec4(0.3f, 1.0f, 0.3f, alpha);
break;
case game::CombatTextEntry::MISS:
snprintf(text, sizeof(text), "Miss");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
case game::CombatTextEntry::DODGE:
snprintf(text, sizeof(text), "Dodge");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
case game::CombatTextEntry::PARRY:
snprintf(text, sizeof(text), "Parry");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
default:
snprintf(text, sizeof(text), "%d", entry.amount);
color = ImVec4(1.0f, 1.0f, 1.0f, alpha);
break;
}
// Stagger entries horizontally
float xOffset = centerX + (index % 3 - 1) * 80.0f;
ImGui::SetCursorPos(ImVec2(xOffset, yOffset));
ImGui::TextColored(color, "%s", text);
index++;
}
}
ImGui::End();
}
// ============================================================
// Party Frames (Phase 4)
// ============================================================
void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
if (!gameHandler.isInGroup()) return;
const auto& partyData = gameHandler.getPartyData();
float frameY = 120.0f;
ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f));
if (ImGui::Begin("##PartyFrames", nullptr, flags)) {
for (const auto& member : partyData.members) {
ImGui::PushID(static_cast<int>(member.guid));
ImVec4 nameColor = member.isOnline ?
ImVec4(0.3f, 0.8f, 1.0f, 1.0f) :
ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
// Clickable name to target
if (ImGui::Selectable(member.name.c_str(), gameHandler.getTargetGuid() == member.guid)) {
gameHandler.setTarget(member.guid);
}
// Try to show health from entity
auto entity = gameHandler.getEntityManager().getEntity(member.guid);
if (entity && (entity->getType() == game::ObjectType::PLAYER || entity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
uint32_t hp = unit->getHealth();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) :
ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
ImGui::ProgressBar(pct, ImVec2(-1, 12), "");
ImGui::PopStyleColor();
}
}
ImGui::Separator();
ImGui::PopID();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Group Invite Popup (Phase 4)
// ============================================================
void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGroupInvite()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
if (ImGui::Begin("Group Invite", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s has invited you to a group.", gameHandler.getPendingInviterName().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptGroupInvite();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.declineGroupInvite();
}
}
ImGui::End();
}
// ============================================================
// Buff/Debuff Bar (Phase 3)
// ============================================================
void GameScreen::renderBuffBar(game::GameHandler& gameHandler) {
const auto& auras = gameHandler.getPlayerAuras();
if (auras.empty()) return;
// Count non-empty auras
int activeCount = 0;
for (const auto& a : auras) {
if (!a.isEmpty()) activeCount++;
}
if (activeCount == 0) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW - 400, 30), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(390, 0), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
if (ImGui::Begin("##BuffBar", nullptr, flags)) {
int shown = 0;
for (size_t i = 0; i < auras.size() && shown < 16; ++i) {
const auto& aura = auras[i];
if (aura.isEmpty()) continue;
if (shown > 0 && shown % 8 != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(i));
// Green border for buffs, red for debuffs
bool isBuff = (aura.flags & 0x02) != 0; // POSITIVE flag
ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : ImVec4(0.8f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_Button, borderColor);
char label[16];
snprintf(label, sizeof(label), "%u", aura.spellId);
if (ImGui::Button(label, ImVec2(40, 40))) {
// Right-click to cancel own buffs
if (isBuff) {
gameHandler.cancelAura(aura.spellId);
}
}
ImGui::PopStyleColor();
// Duration text
if (aura.durationMs > 0) {
int seconds = aura.durationMs / 1000;
if (seconds < 60) {
ImGui::Text("%ds", seconds);
} else {
ImGui::Text("%dm", seconds / 60);
}
}
ImGui::PopID();
shown++;
}
}
ImGui::End();
ImGui::PopStyleColor();
}
// ============================================================
// Loot Window (Phase 5)
// ============================================================
void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isLootWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
bool open = true;
if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
const auto& loot = gameHandler.getCurrentLoot();
// Gold
if (loot.gold > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc",
loot.getGold(), loot.getSilver(), loot.getCopper());
ImGui::Separator();
}
// Items
for (const auto& item : loot.items) {
ImGui::PushID(item.slotIndex);
char label[64];
snprintf(label, sizeof(label), "Item %u (x%u)", item.itemId, item.count);
if (ImGui::Selectable(label)) {
gameHandler.lootItem(item.slotIndex);
}
ImGui::PopID();
}
if (loot.items.empty() && loot.gold == 0) {
ImGui::TextDisabled("Empty");
}
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(-1, 0))) {
gameHandler.closeLoot();
}
}
ImGui::End();
if (!open) {
gameHandler.closeLoot();
}
}
// ============================================================
// Gossip Window (Phase 5)
// ============================================================
void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isGossipWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always);
bool open = true;
if (ImGui::Begin("NPC Dialog", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
const auto& gossip = gameHandler.getCurrentGossip();
// NPC name (from creature cache)
auto npcEntity = gameHandler.getEntityManager().getEntity(gossip.npcGuid);
if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(npcEntity);
if (!unit->getName().empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str());
ImGui::Separator();
}
}
ImGui::Spacing();
// Gossip options
static const char* gossipIcons[] = {"[Chat]", "[Vendor]", "[Taxi]", "[Trainer]", "[Spiritguide]",
"[Tabardvendor]", "[Battlemaster]", "[Banker]", "[Petitioner]",
"[Tabarddesigner]", "[Auctioneer]"};
for (const auto& opt : gossip.options) {
ImGui::PushID(static_cast<int>(opt.id));
const char* icon = (opt.icon < 11) ? gossipIcons[opt.icon] : "[Option]";
char label[256];
snprintf(label, sizeof(label), "%s %s", icon, opt.text.c_str());
if (ImGui::Selectable(label)) {
gameHandler.selectGossipOption(opt.id);
}
ImGui::PopID();
}
// Quest items
if (!gossip.quests.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Quests:");
for (const auto& quest : gossip.quests) {
ImGui::BulletText("[%d] %s", quest.questLevel, quest.title.c_str());
}
}
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(-1, 0))) {
gameHandler.closeGossip();
}
}
ImGui::End();
if (!open) {
gameHandler.closeGossip();
}
}
// ============================================================
// Vendor Window (Phase 5)
// ============================================================
void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isVendorWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 100), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_FirstUseEver);
bool open = true;
if (ImGui::Begin("Vendor", &open)) {
const auto& vendor = gameHandler.getVendorItems();
if (vendor.items.empty()) {
ImGui::TextDisabled("This vendor has nothing for sale.");
} else {
if (ImGui::BeginTable("VendorTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f);
ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f);
ImGui::TableHeadersRow();
for (const auto& item : vendor.items) {
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(item.slot));
ImGui::TableSetColumnIndex(0);
ImGui::Text("Item %u", item.itemId);
ImGui::TableSetColumnIndex(1);
uint32_t g = item.buyPrice / 10000;
uint32_t s = (item.buyPrice / 100) % 100;
uint32_t c = item.buyPrice % 100;
ImGui::Text("%ug %us %uc", g, s, c);
ImGui::TableSetColumnIndex(2);
if (item.maxCount < 0) {
ImGui::Text("Inf");
} else {
ImGui::Text("%d", item.maxCount);
}
ImGui::TableSetColumnIndex(3);
if (ImGui::SmallButton("Buy")) {
gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
}
ImGui::End();
if (!open) {
// Close vendor - just hide UI, no server packet needed
// The vendor window state will be reset on next interaction
}
}
}} // namespace wowee::ui