Fix online interactions, UI, and inventory sync

This commit is contained in:
Kelsi 2026-02-06 18:34:45 -08:00
parent 7436420cd1
commit fdc614902b
14 changed files with 525 additions and 143 deletions

View file

@ -67,6 +67,9 @@ public:
// Teleport to a spawn preset location (single-player only)
void teleportTo(int presetIndex);
// Render bounds lookup (for click targeting / selection)
bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const;
// Character skin composite state (saved at spawn for re-compositing on equipment change)
const std::string& getBodySkinPath() const { return bodySkinPath_; }
const std::vector<std::string>& getUnderwearPaths() const { return underwearPaths_; }

View file

@ -110,6 +110,7 @@ public:
using CharDeleteCallback = std::function<void(bool success)>;
void setCharDeleteCallback(CharDeleteCallback cb) { charDeleteCallback_ = std::move(cb); }
uint8_t getLastCharDeleteResult() const { return lastCharDeleteResult_; }
/**
* Select and log in with a character
@ -214,6 +215,7 @@ public:
void startAutoAttack(uint64_t targetGuid);
void stopAutoAttack();
bool isAutoAttacking() const { return autoAttacking; }
bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
void updateCombatText(float deltaTime);
@ -332,6 +334,7 @@ public:
void lootTarget(uint64_t guid);
void lootItem(uint8_t slotIndex);
void closeLoot();
void activateSpiritHealer(uint64_t npcGuid);
bool isLootWindowOpen() const { return lootWindowOpen; }
const LootResponseData& getCurrentLoot() const { return currentLoot; }
@ -363,6 +366,8 @@ public:
void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count);
void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count);
void sellItemBySlot(int backpackIndex);
void autoEquipItemBySlot(int backpackIndex);
void useItemBySlot(int backpackIndex);
bool isVendorWindowOpen() const { return vendorWindowOpen; }
const ListInventoryData& getVendorItems() const { return currentVendorItems; }
const ItemQueryResponseData* getItemInfo(uint32_t itemId) const {
@ -467,6 +472,8 @@ private:
void handleItemQueryResponse(network::Packet& packet);
void queryItemInfo(uint32_t entry, uint64_t guid);
void rebuildOnlineInventory();
void detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields);
bool applyInventoryFields(const std::map<uint16_t, uint32_t>& fields);
// ---- Phase 2 handlers ----
void handleAttackStart(network::Packet& packet);
@ -606,11 +613,16 @@ private:
std::unordered_set<uint32_t> pendingItemQueries_;
std::array<uint64_t, 23> equipSlotGuids_{};
std::array<uint64_t, 16> backpackSlotGuids_{};
int invSlotBase_ = -1;
int packSlotBase_ = -1;
std::map<uint16_t, uint32_t> lastPlayerFields_;
bool onlineEquipDirty_ = false;
// ---- Phase 2: Combat ----
bool autoAttacking = false;
uint64_t autoAttackTarget = 0;
bool autoAttackOutOfRange_ = false;
std::unordered_set<uint64_t> hostileAttackers_;
std::vector<CombatTextEntry> combatText;
// ---- Phase 3: Spells ----
@ -674,6 +686,7 @@ private:
WorldConnectFailureCallback onFailure;
CharCreateCallback charCreateCallback_;
CharDeleteCallback charDeleteCallback_;
uint8_t lastCharDeleteResult_ = 0xFF;
bool pendingCharCreateResult_ = false;
bool pendingCharCreateSuccess_ = false;
std::string pendingCharCreateMsg_;

View file

@ -162,11 +162,13 @@ enum class Opcode : uint16_t {
// ---- Phase 5: Item/Equip ----
CMSG_ITEM_QUERY_SINGLE = 0x056,
SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058,
CMSG_USE_ITEM = 0x00AB,
CMSG_AUTOEQUIP_ITEM = 0x10A,
SMSG_INVENTORY_CHANGE_FAILURE = 0x112,
// ---- Death/Respawn ----
CMSG_REPOP_REQUEST = 0x015A,
CMSG_SPIRIT_HEALER_ACTIVATE = 0x0176,
};
} // namespace game

View file

@ -1125,6 +1125,18 @@ public:
static network::Packet build(uint8_t slotIndex);
};
/** CMSG_USE_ITEM packet builder */
class UseItemPacket {
public:
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid);
};
/** CMSG_AUTOEQUIP_ITEM packet builder */
class AutoEquipItemPacket {
public:
static network::Packet build(uint64_t itemGuid);
};
/** CMSG_LOOT_RELEASE packet builder */
class LootReleasePacket {
public:
@ -1274,5 +1286,11 @@ public:
static network::Packet build();
};
/** CMSG_SPIRIT_HEALER_ACTIVATE packet builder */
class SpiritHealerActivatePacket {
public:
static network::Packet build(uint64_t npcGuid);
};
} // namespace game
} // namespace wowee

View file

@ -72,6 +72,7 @@ public:
bool hasAnimation(uint32_t instanceId, uint32_t animationId) const;
bool getAnimationSequences(uint32_t instanceId, std::vector<pipeline::M2Sequence>& out) const;
bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const;
bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const;
/** Attach a weapon model to a character instance at the given attachment point. */
bool attachWeapon(uint32_t charInstanceId, uint32_t attachmentId,

View file

@ -52,12 +52,16 @@ private:
char chatInputBuffer[512] = "";
bool chatInputActive = false;
int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, etc.
bool chatInputMoveCursorToEnd = false;
// UI state
bool showEntityWindow = false;
bool showChatWindow = true;
bool showPlayerInfo = false;
bool refocusChatInput = false;
bool chatWindowLocked = true;
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
bool chatWindowPosInit_ = false;
bool showTeleporter = false;
bool showEscapeMenu = false;
bool showEscapeSettingsNotice = false;

View file

@ -38,6 +38,7 @@ public:
vendorMode_ = enabled;
gameHandler_ = handler;
}
void setGameHandler(game::GameHandler* handler) { gameHandler_ = handler; }
/// Set asset manager for icon/model loading
void setAssetManager(pipeline::AssetManager* am) { assetManager_ = am; }

View file

@ -465,14 +465,12 @@ void Application::update(float deltaTime) {
gameHandler->setOrientation(wowOrientation);
}
// Send movement heartbeat every 500ms while moving
if (renderer && renderer->isMoving()) {
// Send movement heartbeat every 500ms (keeps server position in sync)
if (gameHandler && renderer && !singlePlayerMode) {
movementHeartbeatTimer += deltaTime;
if (movementHeartbeatTimer >= 0.5f) {
movementHeartbeatTimer = 0.0f;
if (gameHandler && !singlePlayerMode) {
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
}
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
}
} else {
movementHeartbeatTimer = 0.0f;
@ -712,7 +710,9 @@ void Application::setupUICallbacks() {
gameHandler->requestCharacterList();
}
} else {
uiManager->getCharacterScreen().setStatus("Delete failed.");
uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF;
uiManager->getCharacterScreen().setStatus(
"Delete failed (code " + std::to_string(static_cast<int>(code)) + ").");
}
});
}
@ -2183,6 +2183,25 @@ std::string Application::getModelPathForDisplayId(uint32_t displayId) const {
return itPath->second;
}
bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const {
if (!renderer || !renderer->getCharacterRenderer()) return false;
uint32_t instanceId = 0;
if (gameHandler && guid == gameHandler->getPlayerGuid()) {
instanceId = renderer->getCharacterInstanceId();
}
if (instanceId == 0) {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
}
if (instanceId == 0 && npcManager) {
instanceId = npcManager->findRenderInstanceId(guid);
}
if (instanceId == 0) return false;
return renderer->getCharacterRenderer()->getInstanceBounds(instanceId, outCenter, outRadius);
}
void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) {
if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return;
@ -2388,9 +2407,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Convert canonical → render coordinates
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
// Convert canonical WoW orientation (0=north) -> render yaw (0=west)
float renderYaw = orientation + glm::radians(90.0f);
// Create instance
uint32_t instanceId = charRenderer->createInstance(modelId, renderPos,
glm::vec3(0.0f, 0.0f, orientation), 1.0f);
glm::vec3(0.0f, 0.0f, renderYaw), 1.0f);
if (instanceId == 0) {
LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec);

View file

@ -900,6 +900,20 @@ void GameHandler::update(float deltaTime) {
if (unit->getHealth() == 0) {
stopAutoAttack();
} else {
// Out-of-range notice (melee)
constexpr float MELEE_RANGE = 5.0f;
float dx = target->getX() - movementInfo.x;
float dy = target->getY() - movementInfo.y;
float dz = target->getZ() - movementInfo.z;
float dist = std::sqrt(dx*dx + dy*dy + dz*dz);
bool outOfRange = dist > MELEE_RANGE;
if (outOfRange && !autoAttackOutOfRange_) {
addSystemChatMessage("Target is out of range.");
autoAttackOutOfRange_ = true;
} else if (!outOfRange && autoAttackOutOfRange_) {
autoAttackOutOfRange_ = false;
}
// Re-send attack swing every 2 seconds to keep server combat alive
swingTimer_ += deltaTime;
if (swingTimer_ >= 2.0f) {
@ -966,9 +980,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_CHAR_DELETE: {
uint8_t result = packet.readUInt8();
bool success = (result == 0x47); // CHAR_DELETE_SUCCESS
lastCharDeleteResult_ = result;
bool success = (result == 0x00 || result == 0x47); // Common success codes
LOG_INFO("SMSG_CHAR_DELETE result: ", (int)result, success ? " (success)" : " (failed)");
if (success) requestCharacterList();
requestCharacterList();
if (charDeleteCallback_) charDeleteCallback_(success);
break;
}
@ -2608,6 +2623,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
// Extract XP / inventory slot fields for player entity
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
lastPlayerFields_ = block.fields;
detectInventorySlotBases(block.fields);
bool slotsChanged = false;
for (const auto& [key, val] : block.fields) {
if (key == 634) { playerXp_ = val; } // PLAYER_XP
@ -2619,28 +2636,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
}
}
else if (key == 632) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE
else if (key >= 322 && key <= 367) {
// PLAYER_FIELD_INV_SLOT_HEAD: equipment slots (23 slots × 2 fields)
int slotIndex = (key - 322) / 2;
bool isLow = ((key - 322) % 2 == 0);
if (slotIndex < 23) {
uint64_t& guid = equipSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
} else if (key >= 368 && key <= 399) {
// PLAYER_FIELD_PACK_SLOT_1: backpack slots (16 slots × 2 fields)
int slotIndex = (key - 368) / 2;
bool isLow = ((key - 368) % 2 == 0);
if (slotIndex < 16) {
uint64_t& guid = backpackSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
}
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
}
break;
@ -2666,6 +2663,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (block.guid == autoAttackTarget) {
stopAutoAttack();
}
hostileAttackers_.erase(block.guid);
// Player death
if (block.guid == playerGuid) {
playerDead_ = true;
@ -2705,6 +2703,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
}
// Update XP / inventory slot fields for player entity
if (block.guid == playerGuid) {
for (const auto& [key, val] : block.fields) {
lastPlayerFields_[key] = val;
}
detectInventorySlotBases(block.fields);
bool slotsChanged = false;
for (const auto& [key, val] : block.fields) {
if (key == 634) {
@ -2730,26 +2732,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerMoneyCopper_ = val;
LOG_INFO("Money updated via VALUES: ", val, " copper");
}
else if (key >= 322 && key <= 367) {
int slotIndex = (key - 322) / 2;
bool isLow = ((key - 322) % 2 == 0);
if (slotIndex < 23) {
uint64_t& guid = equipSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
} else if (key >= 368 && key <= 399) {
int slotIndex = (key - 368) / 2;
bool isLow = ((key - 368) % 2 == 0);
if (slotIndex < 16) {
uint64_t& guid = backpackSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
}
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
}
@ -2791,6 +2775,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
tabCycleStale = true;
LOG_INFO("Entity count: ", entityManager.getEntityCount());
// Late inventory base detection once items are known
if (playerGuid != 0 && invSlotBase_ < 0 && !lastPlayerFields_.empty() && !onlineItems_.empty()) {
detectInventorySlotBases(lastPlayerFields_);
if (invSlotBase_ >= 0) {
if (applyInventoryFields(lastPlayerFields_)) {
rebuildOnlineInventory();
}
}
}
}
void GameHandler::handleCompressedUpdateObject(network::Packet& packet) {
@ -2856,6 +2850,7 @@ void GameHandler::handleDestroyObject(network::Packet& packet) {
if (data.guid == targetGuid) {
targetGuid = 0;
}
hostileAttackers_.erase(data.guid);
// Remove online item tracking
if (onlineItems_.erase(data.guid)) {
@ -2975,6 +2970,14 @@ void GameHandler::releaseSpirit() {
}
}
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
if (!playerDead_) return;
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = SpiritHealerActivatePacket::build(npcGuid);
socket->send(packet);
LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE to 0x", std::hex, npcGuid, std::dec);
}
void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
// Rebuild cycle list if stale
if (tabCycleStale) {
@ -3117,6 +3120,65 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) {
}
}
void GameHandler::detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields) {
if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return;
if (onlineItems_.empty() || fields.empty()) return;
std::vector<uint16_t> matchingPairs;
matchingPairs.reserve(32);
for (const auto& [idx, low] : fields) {
if ((idx % 2) != 0) continue;
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
if (itHigh == fields.end()) continue;
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
if (guid == 0) continue;
if (onlineItems_.count(guid)) {
matchingPairs.push_back(idx);
}
}
if (matchingPairs.empty()) return;
std::sort(matchingPairs.begin(), matchingPairs.end());
if (invSlotBase_ < 0) {
invSlotBase_ = matchingPairs.front();
packSlotBase_ = invSlotBase_ + (game::Inventory::NUM_EQUIP_SLOTS * 2);
LOG_INFO("Detected inventory field base: equip=", invSlotBase_,
" pack=", packSlotBase_);
}
}
bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& fields) {
bool slotsChanged = false;
int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : 322;
int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : 368;
for (const auto& [key, val] : fields) {
if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) {
int slotIndex = (key - equipBase) / 2;
bool isLow = ((key - equipBase) % 2 == 0);
if (slotIndex < static_cast<int>(equipSlotGuids_.size())) {
uint64_t& guid = equipSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
} else if (key >= packBase && key <= packBase + (game::Inventory::BACKPACK_SLOTS * 2 - 1)) {
int slotIndex = (key - packBase) / 2;
bool isLow = ((key - packBase) % 2 == 0);
if (slotIndex < static_cast<int>(backpackSlotGuids_.size())) {
uint64_t& guid = backpackSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
}
return slotsChanged;
}
void GameHandler::rebuildOnlineInventory() {
if (singlePlayerMode_) return;
@ -3151,6 +3213,7 @@ void GameHandler::rebuildOnlineInventory() {
def.spirit = infoIt->second.spirit;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setEquipSlot(static_cast<EquipSlot>(i), def);
@ -3185,6 +3248,7 @@ void GameHandler::rebuildOnlineInventory() {
def.spirit = infoIt->second.spirit;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setBackpackSlot(i, def);
@ -3206,6 +3270,7 @@ void GameHandler::rebuildOnlineInventory() {
void GameHandler::startAutoAttack(uint64_t targetGuid) {
autoAttacking = true;
autoAttackTarget = targetGuid;
autoAttackOutOfRange_ = false;
swingTimer_ = 0.0f;
if (state == WorldState::IN_WORLD && socket) {
auto packet = AttackSwingPacket::build(targetGuid);
@ -3218,6 +3283,7 @@ void GameHandler::stopAutoAttack() {
if (!autoAttacking) return;
autoAttacking = false;
autoAttackTarget = 0;
autoAttackOutOfRange_ = false;
if (state == WorldState::IN_WORLD && socket) {
auto packet = AttackStopPacket::build();
socket->send(packet);
@ -3265,6 +3331,8 @@ void GameHandler::handleAttackStop(network::Packet& packet) {
// We'll re-send CMSG_ATTACKSWING periodically in the update loop.
if (data.attackerGuid == playerGuid) {
LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)");
} else if (data.victimGuid == playerGuid) {
hostileAttackers_.erase(data.attackerGuid);
}
}
@ -3345,6 +3413,10 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
npcSwingCallback_(data.attackerGuid);
}
if (isPlayerTarget && data.attackerGuid != 0) {
hostileAttackers_.insert(data.attackerGuid);
}
if (data.isMiss()) {
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker);
} else if (data.victimState == 1) {
@ -3921,6 +3993,40 @@ void GameHandler::sellItemBySlot(int backpackIndex) {
}
}
void GameHandler::autoEquipItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
if (singlePlayerMode_) {
// Fall back to local equip logic (UI already handles this).
return;
}
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
auto packet = AutoEquipItemPacket::build(itemGuid);
socket->send(packet);
}
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
if (singlePlayerMode_) {
// Single-player consumable use not implemented yet.
return;
}
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
auto packet = UseItemPacket::build(0xFF, static_cast<uint8_t>(backpackIndex), itemGuid);
socket->send(packet);
}
}
void GameHandler::handleLootResponse(network::Packet& packet) {
if (!LootResponseParser::parse(packet, currentLoot)) return;
lootWindowOpen = true;

View file

@ -1906,6 +1906,27 @@ network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) {
return packet;
}
network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_USE_ITEM));
packet.writeUInt8(bagIndex);
packet.writeUInt8(slotIndex);
packet.writeUInt8(0); // spell index
packet.writeUInt8(0); // cast count
packet.writeUInt32(0); // spell id (unused)
packet.writeUInt64(itemGuid);
packet.writeUInt32(0); // glyph index
packet.writeUInt8(0); // cast flags
// SpellCastTargets: self
packet.writeUInt32(0x00);
return packet;
}
network::Packet AutoEquipItemPacket::build(uint64_t itemGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_AUTOEQUIP_ITEM));
packet.writeUInt64(itemGuid);
return packet;
}
network::Packet LootReleasePacket::build(uint64_t lootGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LOOT_RELEASE));
packet.writeUInt64(lootGuid);
@ -2122,5 +2143,11 @@ network::Packet RepopRequestPacket::build() {
return packet;
}
network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE));
packet.writeUInt64(npcGuid);
return packet;
}
} // namespace game
} // namespace wowee

View file

@ -79,8 +79,8 @@ void CameraController::update(float deltaTime) {
auto& input = core::Input::getInstance();
// Don't process keyboard input when UI (e.g. chat box) has focus
bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
// Don't process keyboard input when UI text input (e.g. chat box) has focus
bool uiWantsKeyboard = ImGui::GetIO().WantTextInput;
// Determine current key states
bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W);

View file

@ -1415,10 +1415,11 @@ glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) c
// Apply transformations: T * R * S
model = glm::translate(model, instance.position);
// Apply rotation (euler angles)
model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Yaw
// Apply rotation (euler angles, Z-up)
// Convention: yaw around Z, pitch around X, roll around Y.
model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Yaw
model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch
model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Roll
model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll
model = glm::scale(model, glm::vec3(instance.scale));
@ -1697,6 +1698,27 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen
return true;
}
bool CharacterRenderer::getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const {
auto it = instances.find(instanceId);
if (it == instances.end()) return false;
auto mIt = models.find(it->second.modelId);
if (mIt == models.end()) return false;
const auto& inst = it->second;
const auto& model = mIt->second.data;
glm::vec3 localCenter = (model.boundMin + model.boundMax) * 0.5f;
float radius = model.boundRadius;
if (radius <= 0.001f) {
radius = glm::length(model.boundMax - model.boundMin) * 0.5f;
}
float scale = std::max(0.001f, inst.scale);
outCenter = inst.position + localCenter * scale;
outRadius = std::max(0.5f, radius * scale);
return true;
}
void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) {
auto charIt = instances.find(charInstanceId);
if (charIt == instances.end()) return;

View file

@ -17,7 +17,9 @@
#include "pipeline/blp_loader.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <unordered_set>
namespace {
@ -138,6 +140,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
}
// Bags (B key toggle handled inside)
inventoryScreen.setGameHandler(&gameHandler);
inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper());
// Character screen (C key toggle handled inside render())
@ -174,11 +177,19 @@ void GameScreen::render(game::GameHandler& gameHandler) {
// Selection circle color: WoW-canonical level-based colors
glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow
float circleRadius = 1.5f;
{
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
if (core::Application::getInstance().getRenderBoundsForGuid(target->getGuid(), boundsCenter, boundsRadius)) {
float r = boundsRadius * 1.1f;
circleRadius = std::min(std::max(r, 0.8f), 8.0f);
}
}
if (target->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead)
} else if (unit->isHostile()) {
} else if (unit->isHostile() || gameHandler.isAggressiveTowardPlayer(target->getGuid())) {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = unit->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
@ -363,9 +374,24 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
float chatH = 220.0f;
float chatX = 8.0f;
float chatY = screenH - chatH - 80.0f; // Above action bar
ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always);
ImGui::SetNextWindowPos(ImVec2(chatX, chatY), ImGuiCond_Always);
ImGui::Begin("Chat", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove);
if (!chatWindowPosInit_) {
chatWindowPos_ = ImVec2(chatX, chatY);
chatWindowPosInit_ = true;
}
if (chatWindowLocked) {
ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always);
ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_Always);
} else {
ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver);
}
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize;
if (chatWindowLocked) flags |= ImGuiWindowFlags_NoMove;
ImGui::Begin("Chat", nullptr, flags);
if (!chatWindowLocked) {
chatWindowPos_ = ImGui::GetWindowPos();
}
// Chat history
const auto& chatHistory = gameHandler.getChatHistory();
@ -404,6 +430,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::Separator();
ImGui::Spacing();
// Lock toggle
ImGui::Checkbox("Lock", &chatWindowLocked);
ImGui::SameLine();
ImGui::TextDisabled(chatWindowLocked ? "(locked)" : "(movable)");
// Chat input
ImGui::Text("Type:");
ImGui::SameLine();
@ -420,7 +451,20 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::SetKeyboardFocusHere();
refocusChatInput = false;
}
if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int {
auto* self = static_cast<GameScreen*>(data->UserData);
if (self && self->chatInputMoveCursorToEnd) {
int len = static_cast<int>(std::strlen(data->Buf));
data->CursorPos = len;
data->SelectionStart = len;
data->SelectionEnd = len;
self->chatInputMoveCursorToEnd = false;
}
return 0;
};
ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways;
if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) {
sendChatMessage(gameHandler);
refocusChatInput = true;
}
@ -486,6 +530,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
refocusChatInput = true;
chatInputBuffer[0] = '/';
chatInputBuffer[1] = '\0';
chatInputMoveCursorToEnd = true;
}
// Enter key: focus chat input (empty)
@ -516,23 +561,29 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue;
if (guid == myGuid) continue; // Don't target self
// Scale hitbox based on entity type
float hitRadius = 1.5f;
float heightOffset = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
// Critters have very low max health (< 100)
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
// Fallback hitbox based on entity type
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
// Critters have very low max health (< 100)
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
}
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
glm::vec3 entityGL = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
entityGL.z += heightOffset;
float hitT;
if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) {
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
@ -569,20 +620,27 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue;
if (guid == myGuid) continue;
float hitRadius = 1.5f;
float heightOffset = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
glm::vec3 entityGL = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
entityGL.z += heightOffset;
float hitT;
if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) {
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
@ -603,26 +661,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
gameHandler.lootTarget(target->getGuid());
} else if (gameHandler.isSinglePlayerMode()) {
// Single-player: interact with friendly NPCs, attack hostiles
// Single-player: interact with friendly NPCs, otherwise attack
if (!unit->isHostile() && unit->isInteractable()) {
gameHandler.interactWithNpc(target->getGuid());
} else if (unit->isHostile()) {
if (gameHandler.isAutoAttacking()) {
gameHandler.stopAutoAttack();
} else {
gameHandler.startAutoAttack(target->getGuid());
}
} else {
gameHandler.startAutoAttack(target->getGuid());
}
} else {
// Online mode: interact with friendly NPCs, attack hostiles
// Online mode: interact with friendly NPCs, otherwise attack
if (!unit->isHostile() && unit->isInteractable()) {
gameHandler.interactWithNpc(target->getGuid());
} else if (unit->isHostile()) {
if (gameHandler.isAutoAttacking()) {
gameHandler.stopAutoAttack();
} else {
gameHandler.startAutoAttack(target->getGuid());
}
} else {
gameHandler.startAutoAttack(target->getGuid());
}
}
} else if (target->getType() == game::ObjectType::PLAYER) {
@ -634,6 +684,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
}
void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
bool isDead = gameHandler.isPlayerDead();
ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always);
@ -643,7 +694,12 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 1.0f));
ImVec4 playerBorder = isDead
? ImVec4(0.5f, 0.5f, 0.5f, 1.0f)
: (gameHandler.isAutoAttacking()
? ImVec4(1.0f, 0.2f, 0.2f, 1.0f)
: ImVec4(0.4f, 0.4f, 0.4f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_Border, playerBorder);
if (ImGui::Begin("##PlayerFrame", nullptr, flags)) {
// Use selected character info if available, otherwise defaults
@ -671,6 +727,10 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", playerLevel);
if (isDead) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD");
}
// Try to get real HP/mana from the player entity
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
@ -690,7 +750,8 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
// 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));
ImVec4 hpColor = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) : ImVec4(0.2f, 0.8f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor);
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
@ -773,7 +834,11 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f));
ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f);
if (gameHandler.isAutoAttacking()) {
borderColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_Border, borderColor);
if (ImGui::Begin("##TargetFrame", nullptr, flags)) {
// Entity name and type
@ -1471,8 +1536,6 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) {
if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized)
uint32_t currentXp = gameHandler.getPlayerXp();
uint32_t level = gameHandler.getPlayerLevel();
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;
@ -1485,7 +1548,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) {
float barH = slotSize + 24.0f;
float actionBarY = screenH - barH;
float xpBarH = 14.0f;
float xpBarH = 20.0f;
float xpBarW = barW;
float xpBarX = (screenW - xpBarW) / 2.0f;
float xpBarY = actionBarY - xpBarH - 2.0f;
@ -1506,14 +1569,38 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) {
float pct = static_cast<float>(currentXp) / static_cast<float>(nextLevelXp);
if (pct > 1.0f) pct = 1.0f;
// Purple XP bar (WoW-style)
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.58f, 0.2f, 0.93f, 1.0f));
// Custom segmented XP bar (20 bubbles)
ImVec2 barMin = ImGui::GetCursorScreenPos();
ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f);
ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y);
auto* drawList = ImGui::GetWindowDrawList();
ImU32 bg = IM_COL32(15, 15, 20, 220);
ImU32 fg = IM_COL32(148, 51, 238, 255);
ImU32 seg = IM_COL32(35, 35, 45, 255);
drawList->AddRectFilled(barMin, barMax, bg, 2.0f);
drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f);
float fillW = barSize.x * pct;
if (fillW > 0.0f) {
drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f);
}
const int segments = 20;
float segW = barSize.x / static_cast<float>(segments);
for (int i = 1; i < segments; ++i) {
float x = barMin.x + segW * i;
drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f);
}
char overlay[96];
snprintf(overlay, sizeof(overlay), "Lv %u - %u / %u XP", level, currentXp, nextLevelXp);
ImGui::ProgressBar(pct, ImVec2(-1, xpBarH - 4.0f), overlay);
snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp);
ImVec2 textSize = ImGui::CalcTextSize(overlay);
float tx = barMin.x + (barSize.x - textSize.x) * 0.5f;
float ty = barMin.y + (barSize.y - textSize.y) * 0.5f;
drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay);
ImGui::PopStyleColor();
ImGui::Dummy(barSize);
}
ImGui::End();
@ -1857,6 +1944,9 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) {
lootSlotClicked = item.slotIndex;
}
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
lootSlotClicked = item.slotIndex;
}
bool hovered = ImGui::IsItemHovered();
ImDrawList* drawList = ImGui::GetWindowDrawList();
@ -1906,7 +1996,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
}
if (loot.items.empty() && loot.gold == 0) {
ImGui::TextDisabled("Empty");
gameHandler.closeLoot();
}
ImGui::Spacing();
@ -1961,7 +2051,13 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) {
char label[256];
snprintf(label, sizeof(label), "%s %s", icon, opt.text.c_str());
if (ImGui::Selectable(label)) {
gameHandler.selectGossipOption(opt.id);
if (opt.icon == 4) { // Spirit guide
gameHandler.selectGossipOption(opt.id);
gameHandler.activateSpiritHealer(gossip.npcGuid);
gameHandler.closeGossip();
} else {
gameHandler.selectGossipOption(opt.id);
}
}
ImGui::PopID();
}

View file

@ -371,6 +371,12 @@ void InventoryScreen::pickupFromEquipment(game::Inventory& inv, game::EquipSlot
void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) {
if (!holdingItem) return;
if (gameHandler_ && !gameHandler_->isSinglePlayerMode() &&
heldSource == HeldSource::EQUIPMENT) {
// Online mode: avoid client-side unequip; wait for server update.
cancelPickup(inv);
return;
}
const auto& target = inv.getBackpackSlot(index);
if (target.empty()) {
inv.setBackpackSlot(index, heldItem);
@ -388,6 +394,19 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) {
void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slot) {
if (!holdingItem) return;
if (gameHandler_ && !gameHandler_->isSinglePlayerMode()) {
if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) {
// Online mode: request server auto-equip and keep local state intact.
gameHandler_->autoEquipItemBySlot(heldBackpackIndex);
cancelPickup(inv);
return;
}
if (heldSource == HeldSource::EQUIPMENT) {
// Online mode: avoid client-side equipment swaps.
cancelPickup(inv);
return;
}
}
// Validate: check if the held item can go in this slot
if (heldItem.inventoryType > 0) {
@ -588,8 +607,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
ImGui::End();
// Detect held item dropped outside inventory windows → drop confirmation
if (holdingItem && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) {
if (holdingItem && heldItem.itemId != 6948 && ImGui::IsMouseReleased(ImGuiMouseButton_Left) &&
!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) &&
!ImGui::IsAnyItemHovered() && !ImGui::IsAnyItemActive()) {
dropConfirmOpen_ = true;
dropItemName_ = heldItem.name;
}
@ -675,14 +695,51 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
if (clamped) ImGui::SetWindowPos(pos);
}
renderEquipmentPanel(inventory);
if (ImGui::BeginTabBar("##CharacterTabs")) {
if (ImGui::BeginTabItem("Equipment")) {
renderEquipmentPanel(inventory);
ImGui::EndTabItem();
}
// Stats panel — use full width and separate from equipment layout
ImGui::SetCursorPosX(ImGui::GetStyle().WindowPadding.x);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
renderStatsPanel(inventory, gameHandler.getPlayerLevel());
if (ImGui::BeginTabItem("Stats")) {
ImGui::Spacing();
renderStatsPanel(inventory, gameHandler.getPlayerLevel());
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Skills")) {
uint32_t level = gameHandler.getPlayerLevel();
uint32_t cap = (level > 0) ? (level * 5) : 0;
ImGui::TextDisabled("Skills (online sync pending)");
ImGui::Spacing();
if (ImGui::BeginTable("SkillsTable", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH)) {
ImGui::TableSetupColumn("Skill", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableHeadersRow();
const char* skills[] = {
"Unarmed", "Swords", "Axes", "Maces", "Daggers",
"Staves", "Polearms", "Bows", "Guns", "Crossbows"
};
for (const char* skill : skills) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::Text("%s", skill);
ImGui::TableSetColumnIndex(1);
if (cap > 0) {
ImGui::Text("-- / %u", cap);
} else {
ImGui::TextDisabled("--");
}
}
ImGui::EndTable();
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::End();
@ -1039,34 +1096,44 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
equipmentDirty = true;
inventoryDirty = true;
}
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0 && item.inventoryType > 0) {
// Auto-equip
uint8_t equippingType = item.inventoryType;
game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory);
if (targetSlot != game::EquipSlot::NUM_SLOTS) {
const auto& eqSlot = inventory.getEquipSlot(targetSlot);
if (eqSlot.empty()) {
inventory.setEquipSlot(targetSlot, item);
inventory.clearBackpackSlot(backpackIndex);
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
if (gameHandler_ && !gameHandler_->isSinglePlayerMode()) {
if (item.inventoryType > 0) {
// Auto-equip (online)
gameHandler_->autoEquipItemBySlot(backpackIndex);
} else {
game::ItemDef equippedItem = eqSlot.item;
inventory.setEquipSlot(targetSlot, item);
inventory.setBackpackSlot(backpackIndex, equippedItem);
// Use consumable (online)
gameHandler_->useItemBySlot(backpackIndex);
}
if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) {
const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND);
if (!offHand.empty()) {
inventory.addItem(offHand.item);
inventory.clearEquipSlot(game::EquipSlot::OFF_HAND);
} else if (item.inventoryType > 0) {
// Auto-equip (single-player)
uint8_t equippingType = item.inventoryType;
game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory);
if (targetSlot != game::EquipSlot::NUM_SLOTS) {
const auto& eqSlot = inventory.getEquipSlot(targetSlot);
if (eqSlot.empty()) {
inventory.setEquipSlot(targetSlot, item);
inventory.clearBackpackSlot(backpackIndex);
} else {
game::ItemDef equippedItem = eqSlot.item;
inventory.setEquipSlot(targetSlot, item);
inventory.setBackpackSlot(backpackIndex, equippedItem);
}
if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) {
const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND);
if (!offHand.empty()) {
inventory.addItem(offHand.item);
inventory.clearEquipSlot(game::EquipSlot::OFF_HAND);
}
}
if (targetSlot == game::EquipSlot::OFF_HAND &&
inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) {
inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item);
inventory.clearEquipSlot(game::EquipSlot::MAIN_HAND);
}
equipmentDirty = true;
inventoryDirty = true;
}
if (targetSlot == game::EquipSlot::OFF_HAND &&
inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) {
inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item);
inventory.clearEquipSlot(game::EquipSlot::MAIN_HAND);
}
equipmentDirty = true;
inventoryDirty = true;
}
}
}