From e6e3093467a0bfd1863d310c7abb61ab2f73271d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 5 Feb 2026 14:01:26 -0800 Subject: [PATCH] Emulate server loot/xp and combat feedback in single-player --- include/audio/activity_sound_manager.hpp | 4 + include/game/game_handler.hpp | 22 +- include/game/npc_manager.hpp | 1 + include/network/packet.hpp | 1 + include/rendering/character_renderer.hpp | 1 + include/rendering/renderer.hpp | 9 +- include/ui/inventory_screen.hpp | 2 +- src/audio/activity_sound_manager.cpp | 33 ++ src/core/application.cpp | 7 + src/game/game_handler.cpp | 561 ++++++++++++++++++++++- src/game/npc_manager.cpp | 47 +- src/network/packet.cpp | 6 + src/rendering/character_renderer.cpp | 16 + src/rendering/renderer.cpp | 131 +++++- src/ui/game_screen.cpp | 2 +- src/ui/inventory_screen.cpp | 10 +- 16 files changed, 824 insertions(+), 29 deletions(-) diff --git a/include/audio/activity_sound_manager.hpp b/include/audio/activity_sound_manager.hpp index 0c70ddc1..8de9963a 100644 --- a/include/audio/activity_sound_manager.hpp +++ b/include/audio/activity_sound_manager.hpp @@ -29,6 +29,7 @@ public: void setCharacterVoiceProfile(const std::string& modelName); void playWaterEnter(); void playWaterExit(); + void playMeleeSwing(); private: struct Sample { @@ -48,6 +49,7 @@ private: std::vector splashExitClips; std::vector swimLoopClips; std::vector hardLandClips; + std::vector meleeSwingClips; std::array landingSets; bool swimmingActive = false; @@ -61,6 +63,8 @@ private: std::chrono::steady_clock::time_point lastJumpAt{}; std::chrono::steady_clock::time_point lastLandAt{}; std::chrono::steady_clock::time_point lastSplashAt{}; + std::chrono::steady_clock::time_point lastMeleeSwingAt{}; + bool meleeSwingWarned = false; std::string voiceProfileKey; void preloadCandidates(std::vector& out, const std::vector& candidates); diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b91fdb70..8834d407 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -159,6 +159,9 @@ public: */ void addLocalChatMessage(const MessageChatData& msg); + // Money (copper) + uint64_t getMoneyCopper() const { return playerMoneyCopper_; } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -212,6 +215,10 @@ public: using NpcDeathCallback = std::function; void setNpcDeathCallback(NpcDeathCallback cb) { npcDeathCallback_ = std::move(cb); } + // Melee swing callback (for driving animation/SFX) + using MeleeSwingCallback = std::function; + void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } + // Local player stats (single-player) uint32_t getLocalPlayerHealth() const { return localPlayerHealth_; } uint32_t getLocalPlayerMaxHealth() const { return localPlayerMaxHealth_; } @@ -378,6 +385,12 @@ private: void handleGossipMessage(network::Packet& packet); void handleGossipComplete(network::Packet& packet); void handleListInventory(network::Packet& packet); + LootResponseData generateLocalLoot(uint64_t guid); + void simulateLootResponse(const LootResponseData& data); + void simulateLootRelease(); + void simulateLootRemove(uint8_t slotIndex); + void simulateXpGain(uint64_t victimGuid, uint32_t totalXp); + void addMoneyCopper(uint32_t amount); void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource); void addSystemChatMessage(const std::string& message); @@ -484,6 +497,12 @@ private: // ---- Phase 5: Loot ---- bool lootWindowOpen = false; LootResponseData currentLoot; + struct LocalLootState { + LootResponseData data; + bool moneyTaken = false; + }; + std::unordered_map localLootState_; + uint64_t playerMoneyCopper_ = 0; // Gossip bool gossipWindowOpen = false; @@ -501,7 +520,7 @@ private: uint32_t playerXp_ = 0; uint32_t playerNextLevelXp_ = 0; uint32_t serverPlayerLevel_ = 1; - void awardLocalXp(uint32_t victimLevel); + void awardLocalXp(uint64_t victimGuid, uint32_t victimLevel); void levelUp(); static uint32_t xpForLevel(uint32_t level); static uint32_t killXp(uint32_t playerLevel, uint32_t victimLevel); @@ -511,6 +530,7 @@ private: float swingTimer_ = 0.0f; static constexpr float SWING_SPEED = 2.0f; NpcDeathCallback npcDeathCallback_; + MeleeSwingCallback meleeSwingCallback_; uint32_t localPlayerHealth_ = 0; uint32_t localPlayerMaxHealth_ = 0; uint32_t localPlayerLevel_ = 1; diff --git a/include/game/npc_manager.hpp b/include/game/npc_manager.hpp index 81b405c3..cdfc4ae8 100644 --- a/include/game/npc_manager.hpp +++ b/include/game/npc_manager.hpp @@ -16,6 +16,7 @@ class EntityManager; struct NpcSpawnDef { std::string mapName; + uint32_t entry = 0; std::string name; std::string m2Path; uint32_t level; diff --git a/include/network/packet.hpp b/include/network/packet.hpp index 71db41ff..4171fbad 100644 --- a/include/network/packet.hpp +++ b/include/network/packet.hpp @@ -17,6 +17,7 @@ public: void writeUInt16(uint16_t value); void writeUInt32(uint32_t value); void writeUInt64(uint64_t value); + void writeFloat(float value); void writeString(const std::string& value); void writeBytes(const uint8_t* data, size_t length); diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 0a603cfc..0a3cea28 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -66,6 +66,7 @@ public: void removeInstance(uint32_t instanceId); bool getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) const; bool hasAnimation(uint32_t instanceId, uint32_t animationId) const; + bool getAnimationSequences(uint32_t instanceId, std::vector& out) const; bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const; /** Attach a weapon model to a character instance at the given attachment point. */ diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 3691bbf2..3faa2c97 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -121,6 +121,7 @@ public: // Targeting support void setTargetPosition(const glm::vec3* pos); bool isMoving() const; + void triggerMeleeSwing(); // CPU timing stats (milliseconds, last frame). double getLastUpdateMs() const { return lastUpdateMs; } @@ -198,12 +199,13 @@ private: float characterYaw = 0.0f; // Character animation state - enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM }; + enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING }; CharAnimState charAnimState = CharAnimState::IDLE; void updateCharacterAnimation(); bool isFootstepAnimationState() const; bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs); audio::FootstepSurface resolveFootstepSurface() const; + uint32_t resolveMeleeAnimId(); // Emote state bool emoteActive = false; @@ -223,6 +225,11 @@ private: bool sfxPrevFalling = false; bool sfxPrevSwimming = false; + float meleeSwingTimer = 0.0f; + float meleeSwingCooldown = 0.0f; + float meleeAnimDurationMs = 0.0f; + uint32_t meleeAnimId = 0; + bool terrainEnabled = true; bool terrainLoaded = false; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 874afdb9..39f218ad 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -8,7 +8,7 @@ namespace ui { class InventoryScreen { public: - void render(game::Inventory& inventory); + void render(game::Inventory& inventory, uint64_t moneyCopper); bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } diff --git a/src/audio/activity_sound_manager.cpp b/src/audio/activity_sound_manager.cpp index 3aaa4d60..1560652b 100644 --- a/src/audio/activity_sound_manager.cpp +++ b/src/audio/activity_sound_manager.cpp @@ -53,6 +53,21 @@ bool ActivitySoundManager::initialize(pipeline::AssetManager* assets) { preloadLandingSet(FootstepSurface::WATER, "Water"); preloadLandingSet(FootstepSurface::SNOW, "Snow"); + preloadCandidates(meleeSwingClips, { + "Sound\\Item\\Weapons\\Sword\\SwordSwing1.wav", + "Sound\\Item\\Weapons\\Sword\\SwordSwing2.wav", + "Sound\\Item\\Weapons\\Sword\\SwordSwing3.wav", + "Sound\\Item\\Weapons\\Sword\\SwordHit1.wav", + "Sound\\Item\\Weapons\\Sword\\SwordHit2.wav", + "Sound\\Item\\Weapons\\Sword\\SwordHit3.wav", + "Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing1.wav", + "Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing2.wav", + "Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing3.wav", + "Sound\\Item\\Weapons\\Melee\\MeleeSwing1.wav", + "Sound\\Item\\Weapons\\Melee\\MeleeSwing2.wav", + "Sound\\Item\\Weapons\\Melee\\MeleeSwing3.wav" + }); + initialized = true; core::Logger::getInstance().info("Activity SFX loaded: jump=", jumpClips.size(), " splash=", splashEnterClips.size(), @@ -71,6 +86,7 @@ void ActivitySoundManager::shutdown() { splashExitClips.clear(); swimLoopClips.clear(); hardLandClips.clear(); + meleeSwingClips.clear(); swimmingActive = false; swimMoving = false; initialized = false; @@ -275,6 +291,23 @@ void ActivitySoundManager::playLanding(FootstepSurface surface, bool hardLanding } } +void ActivitySoundManager::playMeleeSwing() { + if (meleeSwingClips.empty()) { + if (!meleeSwingWarned) { + core::Logger::getInstance().warning("No melee swing SFX found in assets"); + meleeSwingWarned = true; + } + return; + } + auto now = std::chrono::steady_clock::now(); + if (lastMeleeSwingAt.time_since_epoch().count() != 0) { + if (std::chrono::duration(now - lastMeleeSwingAt).count() < 0.12f) return; + } + if (playOneShot(meleeSwingClips, 0.80f, 0.96f, 1.04f)) { + lastMeleeSwingAt = now; + } +} + void ActivitySoundManager::setSwimmingState(bool swimming, bool moving) { swimMoving = moving; if (swimming == swimmingActive) return; diff --git a/src/core/application.cpp b/src/core/application.cpp index 5df10221..fd074759 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -311,6 +311,13 @@ void Application::setState(AppState newState) { // Keep player locomotion WoW-like in both single-player and online modes. cc->setUseWoWSpeed(true); } + if (gameHandler) { + gameHandler->setMeleeSwingCallback([this]() { + if (renderer) { + renderer->triggerMeleeSwing(); + } + }); + } break; case AppState::DISCONNECTED: // Back to auth diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e6d8eda6..4c615881 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5,13 +5,322 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include +#include #include #include #include +#include +#include +#include +#include +#include +#include namespace wowee { namespace game { +namespace { + +struct LootEntryRow { + uint32_t item = 0; + float chance = 0.0f; + uint16_t lootmode = 0; + uint8_t groupid = 0; + int32_t mincountOrRef = 0; + uint8_t maxcount = 1; +}; + +struct CreatureTemplateRow { + uint32_t lootId = 0; + uint32_t minGold = 0; + uint32_t maxGold = 0; +}; + +struct ItemTemplateRow { + uint32_t itemId = 0; + std::string name; + uint32_t displayId = 0; + uint8_t quality = 0; + uint8_t inventoryType = 0; + int32_t maxStack = 1; +}; + +struct SinglePlayerLootDb { + bool loaded = false; + std::string basePath; + std::unordered_map creatureTemplates; + std::unordered_map> creatureLoot; + std::unordered_map> referenceLoot; + std::unordered_map itemTemplates; +}; + +static std::string trimSql(const std::string& s) { + size_t b = 0; + while (b < s.size() && std::isspace(static_cast(s[b]))) b++; + size_t e = s.size(); + while (e > b && std::isspace(static_cast(s[e - 1]))) e--; + return s.substr(b, e - b); +} + +static bool parseInsertTuples(const std::string& line, std::vector& outTuples) { + outTuples.clear(); + size_t valuesPos = line.find("VALUES"); + if (valuesPos == std::string::npos) valuesPos = line.find("values"); + if (valuesPos == std::string::npos) return false; + + bool inQuote = false; + int depth = 0; + size_t tupleStart = std::string::npos; + for (size_t i = valuesPos; i < line.size(); i++) { + char c = line[i]; + if (c == '\'' && (i == 0 || line[i - 1] != '\\')) inQuote = !inQuote; + if (inQuote) continue; + if (c == '(') { + if (depth == 0) tupleStart = i + 1; + depth++; + } else if (c == ')') { + depth--; + if (depth == 0 && tupleStart != std::string::npos && i > tupleStart) { + outTuples.push_back(line.substr(tupleStart, i - tupleStart)); + tupleStart = std::string::npos; + } + } + } + return !outTuples.empty(); +} + +static std::vector splitCsvTuple(const std::string& tuple) { + std::vector cols; + std::string cur; + bool inQuote = false; + for (size_t i = 0; i < tuple.size(); i++) { + char c = tuple[i]; + if (c == '\'' && (i == 0 || tuple[i - 1] != '\\')) { + inQuote = !inQuote; + cur.push_back(c); + continue; + } + if (c == ',' && !inQuote) { + cols.push_back(trimSql(cur)); + cur.clear(); + continue; + } + cur.push_back(c); + } + if (!cur.empty()) cols.push_back(trimSql(cur)); + return cols; +} + +static std::string unquoteSqlString(const std::string& s) { + if (s.size() >= 2 && s.front() == '\'' && s.back() == '\'') { + return s.substr(1, s.size() - 2); + } + return s; +} + +static std::vector loadCreateTableColumns(const std::filesystem::path& path) { + std::vector columns; + std::ifstream in(path); + if (!in) return columns; + std::string line; + bool inCreate = false; + while (std::getline(in, line)) { + if (!inCreate) { + if (line.find("CREATE TABLE") != std::string::npos || + line.find("create table") != std::string::npos) { + inCreate = true; + } + continue; + } + auto trimmed = trimSql(line); + if (trimmed.empty()) continue; + if (trimmed[0] == ')') break; + size_t b = trimmed.find('`'); + if (b == std::string::npos) continue; + size_t e = trimmed.find('`', b + 1); + if (e == std::string::npos) continue; + columns.push_back(trimmed.substr(b + 1, e - b - 1)); + } + return columns; +} + +static int columnIndex(const std::vector& cols, const std::string& name) { + for (size_t i = 0; i < cols.size(); i++) { + if (cols[i] == name) return static_cast(i); + } + return -1; +} + +static std::filesystem::path resolveDbBasePath() { + if (const char* dbBase = std::getenv("WOW_DB_BASE_PATH")) { + std::filesystem::path base(dbBase); + if (std::filesystem::exists(base)) return base; + } + if (std::filesystem::exists("assets/sql")) { + return std::filesystem::path("assets/sql"); + } + return {}; +} + +static void processInsertStatements( + std::ifstream& in, + const std::function&)>& onTuple) { + std::string line; + std::string stmt; + std::vector tuples; + while (std::getline(in, line)) { + if (stmt.empty()) { + if (line.find("INSERT INTO") == std::string::npos && + line.find("insert into") == std::string::npos) { + continue; + } + } + if (!stmt.empty()) stmt.push_back('\n'); + stmt += line; + if (line.find(';') == std::string::npos) continue; + + if (parseInsertTuples(stmt, tuples)) { + for (const auto& t : tuples) { + onTuple(splitCsvTuple(t)); + } + } + stmt.clear(); + } +} + +static SinglePlayerLootDb& getSinglePlayerLootDb() { + static SinglePlayerLootDb db; + if (db.loaded) return db; + + auto base = resolveDbBasePath(); + if (base.empty()) { + db.loaded = true; + return db; + } + + std::filesystem::path basePath = base; + std::filesystem::path creatureTemplatePath = basePath / "creature_template.sql"; + std::filesystem::path creatureLootPath = basePath / "creature_loot_template.sql"; + std::filesystem::path referenceLootPath = basePath / "reference_loot_template.sql"; + std::filesystem::path itemTemplatePath = basePath / "item_template.sql"; + + if (!std::filesystem::exists(creatureTemplatePath)) { + auto alt = basePath / "base"; + if (std::filesystem::exists(alt / "creature_template.sql")) { + basePath = alt; + creatureTemplatePath = basePath / "creature_template.sql"; + creatureLootPath = basePath / "creature_loot_template.sql"; + referenceLootPath = basePath / "reference_loot_template.sql"; + itemTemplatePath = basePath / "item_template.sql"; + } + } + + db.basePath = basePath.string(); + + // creature_template: entry, lootid, mingold, maxgold + { + auto cols = loadCreateTableColumns(creatureTemplatePath); + int idxEntry = columnIndex(cols, "entry"); + int idxLoot = columnIndex(cols, "lootid"); + int idxMinGold = columnIndex(cols, "mingold"); + int idxMaxGold = columnIndex(cols, "maxgold"); + if (idxEntry >= 0 && std::filesystem::exists(creatureTemplatePath)) { + std::ifstream in(creatureTemplatePath); + processInsertStatements(in, [&](const std::vector& row) { + if (idxEntry >= static_cast(row.size())) return; + try { + uint32_t entry = static_cast(std::stoul(row[idxEntry])); + CreatureTemplateRow tr; + if (idxLoot >= 0 && idxLoot < static_cast(row.size())) { + tr.lootId = static_cast(std::stoul(row[idxLoot])); + } + if (idxMinGold >= 0 && idxMinGold < static_cast(row.size())) { + tr.minGold = static_cast(std::stoul(row[idxMinGold])); + } + if (idxMaxGold >= 0 && idxMaxGold < static_cast(row.size())) { + tr.maxGold = static_cast(std::stoul(row[idxMaxGold])); + } + db.creatureTemplates[entry] = tr; + } catch (const std::exception&) { + } + }); + } + } + + auto loadLootTable = [&](const std::filesystem::path& path, + std::unordered_map>& out) { + if (!std::filesystem::exists(path)) return; + std::ifstream in(path); + processInsertStatements(in, [&](const std::vector& row) { + if (row.size() < 7) return; + try { + uint32_t entry = static_cast(std::stoul(row[0])); + LootEntryRow lr; + lr.item = static_cast(std::stoul(row[1])); + lr.chance = std::stof(row[2]); + lr.lootmode = static_cast(std::stoul(row[3])); + lr.groupid = static_cast(std::stoul(row[4])); + lr.mincountOrRef = static_cast(std::stol(row[5])); + lr.maxcount = static_cast(std::stoul(row[6])); + out[entry].push_back(lr); + } catch (const std::exception&) { + } + }); + }; + + loadLootTable(creatureLootPath, db.creatureLoot); + loadLootTable(referenceLootPath, db.referenceLoot); + + // item_template + { + auto cols = loadCreateTableColumns(itemTemplatePath); + int idxEntry = columnIndex(cols, "entry"); + int idxName = columnIndex(cols, "name"); + int idxDisplay = columnIndex(cols, "displayid"); + int idxQuality = columnIndex(cols, "Quality"); + int idxInvType = columnIndex(cols, "InventoryType"); + int idxStack = columnIndex(cols, "stackable"); + if (idxEntry >= 0 && std::filesystem::exists(itemTemplatePath)) { + std::ifstream in(itemTemplatePath); + processInsertStatements(in, [&](const std::vector& row) { + if (idxEntry >= static_cast(row.size())) return; + try { + ItemTemplateRow ir; + ir.itemId = static_cast(std::stoul(row[idxEntry])); + if (idxName >= 0 && idxName < static_cast(row.size())) { + ir.name = unquoteSqlString(row[idxName]); + } + if (idxDisplay >= 0 && idxDisplay < static_cast(row.size())) { + ir.displayId = static_cast(std::stoul(row[idxDisplay])); + } + if (idxQuality >= 0 && idxQuality < static_cast(row.size())) { + ir.quality = static_cast(std::stoul(row[idxQuality])); + } + if (idxInvType >= 0 && idxInvType < static_cast(row.size())) { + ir.inventoryType = static_cast(std::stoul(row[idxInvType])); + } + if (idxStack >= 0 && idxStack < static_cast(row.size())) { + ir.maxStack = static_cast(std::stol(row[idxStack])); + if (ir.maxStack <= 0) ir.maxStack = 1; + } + db.itemTemplates[ir.itemId] = std::move(ir); + } catch (const std::exception&) { + } + }); + } + } + + db.loaded = true; + LOG_INFO("Single-player loot DB loaded from ", db.basePath, + " (creatures=", db.creatureTemplates.size(), + ", loot=", db.creatureLoot.size(), + ", reference=", db.referenceLoot.size(), + ", items=", db.itemTemplates.size(), ")"); + return db; +} + +} // namespace + GameHandler::GameHandler() { LOG_DEBUG("GameHandler created"); @@ -1218,6 +1527,9 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { bool isPlayerAttacker = (data.attackerGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); + if (isPlayerAttacker && meleeSwingCallback_) { + meleeSwingCallback_(); + } if (data.isMiss()) { addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); @@ -1558,12 +1870,71 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { // ============================================================ void GameHandler::lootTarget(uint64_t guid) { + if (singlePlayerMode_) { + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::UNIT) return; + auto unit = std::static_pointer_cast(entity); + if (unit->getHealth() != 0) return; + + auto it = localLootState_.find(guid); + if (it == localLootState_.end()) { + LocalLootState state; + state.data = generateLocalLoot(guid); + it = localLootState_.emplace(guid, std::move(state)).first; + } + simulateLootResponse(it->second.data); + return; + } + if (state != WorldState::IN_WORLD || !socket) return; auto packet = LootPacket::build(guid); socket->send(packet); } void GameHandler::lootItem(uint8_t slotIndex) { + if (singlePlayerMode_) { + if (!lootWindowOpen) return; + auto it = std::find_if(currentLoot.items.begin(), currentLoot.items.end(), + [slotIndex](const LootItem& item) { return item.slotIndex == slotIndex; }); + if (it == currentLoot.items.end()) return; + + auto& db = getSinglePlayerLootDb(); + ItemDef def; + def.itemId = it->itemId; + def.stackCount = it->count; + def.maxStack = it->count; + + auto itTpl = db.itemTemplates.find(it->itemId); + if (itTpl != db.itemTemplates.end()) { + def.name = itTpl->second.name.empty() + ? ("Item " + std::to_string(it->itemId)) + : itTpl->second.name; + def.quality = static_cast(itTpl->second.quality); + def.inventoryType = itTpl->second.inventoryType; + def.maxStack = std::max(def.maxStack, static_cast(itTpl->second.maxStack)); + } else { + def.name = "Item " + std::to_string(it->itemId); + } + + if (inventory.addItem(def)) { + simulateLootRemove(slotIndex); + addSystemChatMessage("You receive item: " + def.name + " x" + std::to_string(def.stackCount) + "."); + if (currentLoot.lootGuid != 0) { + auto st = localLootState_.find(currentLoot.lootGuid); + if (st != localLootState_.end()) { + auto& items = st->second.data.items; + items.erase(std::remove_if(items.begin(), items.end(), + [slotIndex](const LootItem& item) { + return item.slotIndex == slotIndex; + }), + items.end()); + } + } + } else { + addSystemChatMessage("Inventory is full."); + } + return; + } if (state != WorldState::IN_WORLD || !socket) return; auto packet = AutostoreLootItemPacket::build(slotIndex); socket->send(packet); @@ -1572,6 +1943,19 @@ void GameHandler::lootItem(uint8_t slotIndex) { void GameHandler::closeLoot() { if (!lootWindowOpen) return; lootWindowOpen = false; + if (singlePlayerMode_ && currentLoot.lootGuid != 0) { + auto st = localLootState_.find(currentLoot.lootGuid); + if (st != localLootState_.end()) { + if (!st->second.moneyTaken && st->second.data.gold > 0) { + addMoneyCopper(st->second.data.gold); + st->second.moneyTaken = true; + st->second.data.gold = 0; + } + } + currentLoot.gold = 0; + simulateLootRelease(); + return; + } if (state == WorldState::IN_WORLD && socket) { auto packet = LootReleasePacket::build(currentLoot.lootGuid); socket->send(packet); @@ -1699,6 +2083,10 @@ void GameHandler::performPlayerSwing() { auto unit = std::static_pointer_cast(entity); if (unit->getHealth() == 0) return; + if (meleeSwingCallback_) { + meleeSwingCallback_(); + } + // Aggro the target aggroNpc(autoAttackTarget); @@ -1737,7 +2125,7 @@ void GameHandler::handleNpcDeath(uint64_t guid) { auto entity = entityManager.getEntity(guid); if (entity && entity->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); - awardLocalXp(unit->getLevel()); + awardLocalXp(guid, unit->getLevel()); } // Remove from aggro list @@ -1890,7 +2278,7 @@ uint32_t GameHandler::killXp(uint32_t playerLevel, uint32_t victimLevel) { return static_cast(baseXp * multiplier); } -void GameHandler::awardLocalXp(uint32_t victimLevel) { +void GameHandler::awardLocalXp(uint64_t victimGuid, uint32_t victimLevel) { if (localPlayerLevel_ >= 80) return; // Level cap uint32_t xp = killXp(localPlayerLevel_, victimLevel); @@ -1900,6 +2288,7 @@ void GameHandler::awardLocalXp(uint32_t victimLevel) { // Show XP gain in combat text as a heal-type (gold text) addCombatText(CombatTextEntry::HEAL, static_cast(xp), 0, true); + simulateXpGain(victimGuid, xp); LOG_INFO("XP gained: +", xp, " (total: ", playerXp_, "/", playerNextLevelXp_, ")"); @@ -1945,6 +2334,174 @@ void GameHandler::handleXpGain(network::Packet& packet) { addSystemChatMessage(msg); } +LootResponseData GameHandler::generateLocalLoot(uint64_t guid) { + LootResponseData data; + data.lootGuid = guid; + data.lootType = 0; + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::UNIT) return data; + auto unit = std::static_pointer_cast(entity); + uint32_t entry = unit->getEntry(); + if (entry == 0) return data; + + auto& db = getSinglePlayerLootDb(); + + uint32_t lootId = entry; + auto itTemplate = db.creatureTemplates.find(entry); + if (itTemplate != db.creatureTemplates.end()) { + if (itTemplate->second.lootId != 0) lootId = itTemplate->second.lootId; + if (itTemplate->second.maxGold > 0) { + std::uniform_int_distribution goldDist( + itTemplate->second.minGold, itTemplate->second.maxGold); + static std::mt19937 rng(std::random_device{}()); + data.gold = goldDist(rng); + } + } + + auto itLoot = db.creatureLoot.find(lootId); + if (itLoot == db.creatureLoot.end() && lootId != entry) { + itLoot = db.creatureLoot.find(entry); + } + if (itLoot == db.creatureLoot.end()) return data; + + std::unordered_map> groups; + std::vector ungroupped; + for (const auto& row : itLoot->second) { + if (row.groupid == 0) { + ungroupped.push_back(row); + } else { + groups[row.groupid].push_back(row); + } + } + + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution roll(0.0f, 100.0f); + + auto addItem = [&](uint32_t itemId, uint32_t count) { + LootItem li; + li.slotIndex = static_cast(data.items.size()); + li.itemId = itemId; + li.count = count; + auto itItem = db.itemTemplates.find(itemId); + if (itItem != db.itemTemplates.end()) { + li.displayInfoId = itItem->second.displayId; + } + data.items.push_back(li); + }; + + std::function&, bool)> processLootTable; + processLootTable = [&](const std::vector& rows, bool grouped) { + if (rows.empty()) return; + if (grouped) { + float total = 0.0f; + for (const auto& r : rows) total += std::abs(r.chance); + if (total <= 0.0f) return; + float r = roll(rng); + if (total < 100.0f && r > total) return; + float pick = (total < 100.0f) + ? r + : std::uniform_real_distribution(0.0f, total)(rng); + float acc = 0.0f; + for (const auto& row : rows) { + acc += std::abs(row.chance); + if (pick <= acc) { + if (row.mincountOrRef < 0) { + auto refIt = db.referenceLoot.find(static_cast(-row.mincountOrRef)); + if (refIt != db.referenceLoot.end()) { + processLootTable(refIt->second, false); + } + } else { + uint32_t minc = static_cast(std::max(1, row.mincountOrRef)); + uint32_t maxc = std::max(minc, static_cast(row.maxcount)); + std::uniform_int_distribution cnt(minc, maxc); + addItem(row.item, cnt(rng)); + } + break; + } + } + return; + } + + for (const auto& row : rows) { + float chance = std::abs(row.chance); + if (chance <= 0.0f) continue; + if (roll(rng) > chance) continue; + if (row.mincountOrRef < 0) { + auto refIt = db.referenceLoot.find(static_cast(-row.mincountOrRef)); + if (refIt != db.referenceLoot.end()) { + processLootTable(refIt->second, false); + } + continue; + } + uint32_t minc = static_cast(std::max(1, row.mincountOrRef)); + uint32_t maxc = std::max(minc, static_cast(row.maxcount)); + std::uniform_int_distribution cnt(minc, maxc); + addItem(row.item, cnt(rng)); + } + }; + + processLootTable(ungroupped, false); + for (const auto& [gid, rows] : groups) { + processLootTable(rows, true); + } + + return data; +} + +void GameHandler::simulateLootResponse(const LootResponseData& data) { + network::Packet packet(static_cast(Opcode::SMSG_LOOT_RESPONSE)); + packet.writeUInt64(data.lootGuid); + packet.writeUInt8(data.lootType); + packet.writeUInt32(data.gold); + packet.writeUInt8(static_cast(data.items.size())); + for (const auto& item : data.items) { + packet.writeUInt8(item.slotIndex); + packet.writeUInt32(item.itemId); + packet.writeUInt32(item.count); + packet.writeUInt32(item.displayInfoId); + packet.writeUInt32(item.randomSuffix); + packet.writeUInt32(item.randomPropertyId); + packet.writeUInt8(item.lootSlotType); + } + handleLootResponse(packet); +} + +void GameHandler::simulateLootRelease() { + network::Packet packet(static_cast(Opcode::SMSG_LOOT_RELEASE_RESPONSE)); + handleLootReleaseResponse(packet); + currentLoot = LootResponseData{}; +} + +void GameHandler::simulateLootRemove(uint8_t slotIndex) { + if (!lootWindowOpen) return; + network::Packet packet(static_cast(Opcode::SMSG_LOOT_REMOVED)); + packet.writeUInt8(slotIndex); + handleLootRemoved(packet); +} + +void GameHandler::simulateXpGain(uint64_t victimGuid, uint32_t totalXp) { + network::Packet packet(static_cast(Opcode::SMSG_LOG_XPGAIN)); + packet.writeUInt64(victimGuid); + packet.writeUInt32(totalXp); + packet.writeUInt8(0); // kill XP + packet.writeFloat(0.0f); + packet.writeUInt32(0); // group bonus + handleXpGain(packet); +} + +void GameHandler::addMoneyCopper(uint32_t amount) { + if (amount == 0) return; + playerMoneyCopper_ += amount; + uint32_t gold = amount / 10000; + uint32_t silver = (amount / 100) % 100; + uint32_t copper = amount % 100; + std::string msg = "You receive "; + msg += std::to_string(gold) + "g "; + msg += std::to_string(silver) + "s "; + msg += std::to_string(copper) + "c."; + addSystemChatMessage(msg); +} + void GameHandler::addSystemChatMessage(const std::string& message) { if (message.empty()) return; MessageChatData msg; diff --git a/src/game/npc_manager.cpp b/src/game/npc_manager.cpp index 29869b20..a6178d5b 100644 --- a/src/game/npc_manager.cpp +++ b/src/game/npc_manager.cpp @@ -594,19 +594,21 @@ std::vector NpcManager::loadSpawnDefsFromAzerothCoreDb( float dy = canonical.y - playerCanonical.y; if (dx * dx + dy * dy > kRadius * kRadius) return true; - NpcSpawnDef def; - def.mapName = mapName; - auto it = templates.find(entry); - if (it != templates.end()) { - def.name = it->second.name; - def.level = it->second.level; - def.health = std::max(it->second.health, curhealth); - def.m2Path = it->second.m2Path; - } else { - def.name = "Creature " + std::to_string(entry); - def.level = 1; - def.health = std::max(100u, curhealth); - } + NpcSpawnDef def; + def.mapName = mapName; + auto it = templates.find(entry); + if (it != templates.end()) { + def.entry = entry; + def.name = it->second.name; + def.level = it->second.level; + def.health = std::max(it->second.health, curhealth); + def.m2Path = it->second.m2Path; + } else { + def.entry = entry; + def.name = "Creature " + std::to_string(entry); + def.level = 1; + def.health = std::max(100u, curhealth); + } if (def.m2Path.empty()) { def.m2Path = "Creature\\HumanMalePeasant\\HumanMalePeasant.m2"; } @@ -672,21 +674,21 @@ void NpcManager::initialize(pipeline::AssetManager* am, if (spawnDefs.empty()) { LOG_WARNING("NpcManager: using built-in NPC spawns (assets/npcs/singleplayer_spawns.csv missing)"); spawnDefs = { - {"Azeroth", "Innkeeper Farley", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", + {"Azeroth", 0, "Innkeeper Farley", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", 30, 5000, glm::vec3(76.0f, -9468.0f, 205.0f), false, 3.1f, 1.0f, false}, - {"Azeroth", "Bernard Gump", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", + {"Azeroth", 0, "Bernard Gump", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", 25, 4200, glm::vec3(92.0f, -9478.0f, 205.0f), false, 1.2f, 1.0f, false}, - {"Azeroth", "Stormwind Guard", "Creature\\HumanMaleGuard\\HumanMaleGuard.m2", + {"Azeroth", 0, "Stormwind Guard", "Creature\\HumanMaleGuard\\HumanMaleGuard.m2", 60, 42000, glm::vec3(86.0f, -9478.0f, 205.0f), false, 0.1f, 1.0f, false}, - {"Azeroth", "Stormwind Guard", "Creature\\HumanMaleGuard\\HumanMaleGuard.m2", + {"Azeroth", 0, "Stormwind Guard", "Creature\\HumanMaleGuard\\HumanMaleGuard.m2", 60, 42000, glm::vec3(37.0f, -9440.0f, 205.0f), false, 2.8f, 1.0f, false}, - {"Azeroth", "Stormwind Citizen", "Creature\\HumanFemalePeasant\\HumanFemalePeasant.m2", + {"Azeroth", 0, "Stormwind Citizen", "Creature\\HumanFemalePeasant\\HumanFemalePeasant.m2", 5, 1200, glm::vec3(62.0f, -9468.0f, 205.0f), false, 3.5f, 1.0f, false}, - {"Azeroth", "Stormwind Citizen", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", + {"Azeroth", 0, "Stormwind Citizen", "Creature\\HumanMalePeasant\\HumanMalePeasant.m2", 5, 1200, glm::vec3(23.0f, -9518.0f, 205.0f), false, 1.8f, 1.0f, false}, - {"Azeroth", "Chicken", "Creature\\Chicken\\Chicken.m2", + {"Azeroth", 0, "Chicken", "Creature\\Chicken\\Chicken.m2", 1, 10, glm::vec3(58.0f, -9534.0f, 205.0f), false, 2.0f, 1.0f, true}, - {"Azeroth", "Cat", "Creature\\Cat\\Cat.m2", + {"Azeroth", 0, "Cat", "Creature\\Cat\\Cat.m2", 1, 42, glm::vec3(90.0f, -9446.0f, 205.0f), false, 4.5f, 1.0f, true} }; } @@ -763,6 +765,9 @@ void NpcManager::initialize(pipeline::AssetManager* am, unit->setLevel(s.level); unit->setHealth(s.health); unit->setMaxHealth(s.health); + if (s.entry != 0) { + unit->setEntry(s.entry); + } // Store canonical WoW coordinates for targeting/server compatibility glm::vec3 spawnCanonical = core::coords::renderToCanonical(glPos); diff --git a/src/network/packet.cpp b/src/network/packet.cpp index 714c7e21..7c8b55a3 100644 --- a/src/network/packet.cpp +++ b/src/network/packet.cpp @@ -30,6 +30,12 @@ void Packet::writeUInt64(uint64_t value) { writeUInt32((value >> 32) & 0xFFFFFFFF); } +void Packet::writeFloat(float value) { + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(float)); + writeUInt32(bits); +} + void Packet::writeString(const std::string& value) { for (char c : value) { data.push_back(static_cast(c)); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index a92681c9..05cc2aa4 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1404,6 +1404,22 @@ bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId) return false; } +bool CharacterRenderer::getAnimationSequences(uint32_t instanceId, std::vector& out) const { + out.clear(); + auto it = instances.find(instanceId); + if (it == instances.end()) { + return false; + } + + auto modelIt = models.find(it->second.modelId); + if (modelIt == models.end()) { + return false; + } + + out = modelIt->second.data.sequences; + return !out.empty(); +} + bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& modelName) const { auto it = instances.find(instanceId); if (it == instances.end()) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 87a8893d..37c8472d 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -18,6 +18,8 @@ #include "rendering/m2_renderer.hpp" #include "rendering/minimap.hpp" #include "rendering/shader.hpp" +#include "pipeline/m2_loader.hpp" +#include #include "pipeline/asset_manager.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" @@ -362,6 +364,79 @@ void Renderer::setCharacterFollow(uint32_t instanceId) { } } +uint32_t Renderer::resolveMeleeAnimId() { + if (!characterRenderer || characterInstanceId == 0) { + meleeAnimId = 0; + meleeAnimDurationMs = 0.0f; + return 0; + } + + if (meleeAnimId != 0 && characterRenderer->hasAnimation(characterInstanceId, meleeAnimId)) { + return meleeAnimId; + } + + std::vector sequences; + if (!characterRenderer->getAnimationSequences(characterInstanceId, sequences)) { + meleeAnimId = 0; + meleeAnimDurationMs = 0.0f; + return 0; + } + + auto findDuration = [&](uint32_t id) -> float { + for (const auto& seq : sequences) { + if (seq.id == id && seq.duration > 0) { + return static_cast(seq.duration); + } + } + return 0.0f; + }; + + const uint32_t attackCandidates[] = {16, 17, 18, 19, 20, 21}; + for (uint32_t id : attackCandidates) { + if (characterRenderer->hasAnimation(characterInstanceId, id)) { + meleeAnimId = id; + meleeAnimDurationMs = findDuration(id); + return meleeAnimId; + } + } + + const uint32_t avoidIds[] = {0, 1, 4, 5, 11, 12, 13, 37, 38, 39, 41, 42, 97}; + auto isAvoid = [&](uint32_t id) -> bool { + for (uint32_t avoid : avoidIds) { + if (id == avoid) return true; + } + return false; + }; + + uint32_t bestId = 0; + uint32_t bestDuration = 0; + for (const auto& seq : sequences) { + if (seq.duration == 0) continue; + if (isAvoid(seq.id)) continue; + if (seq.movingSpeed > 0.1f) continue; + if (seq.duration < 150 || seq.duration > 2000) continue; + if (bestId == 0 || seq.duration < bestDuration) { + bestId = seq.id; + bestDuration = seq.duration; + } + } + + if (bestId == 0) { + for (const auto& seq : sequences) { + if (seq.duration == 0) continue; + if (isAvoid(seq.id)) continue; + if (bestId == 0 || seq.duration < bestDuration) { + bestId = seq.id; + bestDuration = seq.duration; + } + } + } + + meleeAnimId = bestId; + meleeAnimDurationMs = static_cast(bestDuration); + return meleeAnimId; +} + void Renderer::updateCharacterAnimation() { // WoW WotLK AnimationData.dbc IDs constexpr uint32_t ANIM_STAND = 0; @@ -394,8 +469,9 @@ void Renderer::updateCharacterAnimation() { bool sprinting = cameraController->isSprinting(); bool sitting = cameraController->isSitting(); bool swim = cameraController->isSwimming(); + bool forceMelee = meleeSwingTimer > 0.0f && grounded && !swim; - switch (charAnimState) { + if (!forceMelee) switch (charAnimState) { case CharAnimState::IDLE: if (swim) { newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; @@ -517,6 +593,28 @@ void Renderer::updateCharacterAnimation() { newState = CharAnimState::SWIM_IDLE; } break; + + case CharAnimState::MELEE_SWING: + if (swim) { + newState = CharAnimState::SWIM_IDLE; + } else if (!grounded && jumping) { + newState = CharAnimState::JUMP_START; + } else if (!grounded) { + newState = CharAnimState::JUMP_MID; + } else if (moving && sprinting) { + newState = CharAnimState::RUN; + } else if (moving) { + newState = CharAnimState::WALK; + } else if (sitting) { + newState = CharAnimState::SIT_DOWN; + } else { + newState = CharAnimState::IDLE; + } + break; + } + + if (forceMelee) { + newState = CharAnimState::MELEE_SWING; } if (newState != charAnimState) { @@ -569,6 +667,13 @@ void Renderer::updateCharacterAnimation() { case CharAnimState::EMOTE: animId = emoteAnimId; loop = emoteLoop; break; case CharAnimState::SWIM_IDLE: animId = ANIM_SWIM_IDLE; loop = true; break; case CharAnimState::SWIM: animId = ANIM_SWIM; loop = true; break; + case CharAnimState::MELEE_SWING: + animId = resolveMeleeAnimId(); + if (animId == 0) { + animId = ANIM_STAND; + } + loop = false; + break; } uint32_t currentAnimId = 0; @@ -601,6 +706,23 @@ void Renderer::cancelEmote() { emoteLoop = false; } +void Renderer::triggerMeleeSwing() { + if (!characterRenderer || characterInstanceId == 0) return; + if (meleeSwingCooldown > 0.0f) return; + if (emoteActive) { + cancelEmote(); + } + resolveMeleeAnimId(); + meleeSwingCooldown = 0.1f; + float durationSec = meleeAnimDurationMs > 0.0f ? meleeAnimDurationMs / 1000.0f : 0.6f; + if (durationSec < 0.25f) durationSec = 0.25f; + if (durationSec > 1.0f) durationSec = 1.0f; + meleeSwingTimer = durationSec; + if (activitySoundManager) { + activitySoundManager->playMeleeSwing(); + } +} + std::string Renderer::getEmoteText(const std::string& emoteName) { auto it = EMOTE_TABLE.find(emoteName); if (it != EMOTE_TABLE.end()) { @@ -714,6 +836,13 @@ void Renderer::update(float deltaTime) { // Sync character model position/rotation and animation with follow target if (characterInstanceId > 0 && characterRenderer && cameraController && cameraController->isThirdPerson()) { + if (meleeSwingCooldown > 0.0f) { + meleeSwingCooldown = std::max(0.0f, meleeSwingCooldown - deltaTime); + } + if (meleeSwingTimer > 0.0f) { + meleeSwingTimer = std::max(0.0f, meleeSwingTimer - deltaTime); + } + characterRenderer->setInstancePosition(characterInstanceId, characterPosition); if (activitySoundManager) { std::string modelName; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c5303ab7..8537ec2e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -92,7 +92,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); // Inventory (B key toggle handled inside) - inventoryScreen.render(gameHandler.getInventory()); + inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); if (inventoryScreen.consumeEquipmentDirty()) { updateCharacterGeosets(gameHandler.getInventory()); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 4e40e740..d0036ef2 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -220,7 +220,7 @@ void InventoryScreen::renderHeldItem() { } } -void InventoryScreen::render(game::Inventory& inventory) { +void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { // B key toggle (edge-triggered) bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; bool bDown = !uiWantsKeyboard && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_B); @@ -269,6 +269,14 @@ void InventoryScreen::render(game::Inventory& inventory) { renderBackpackPanel(inventory); ImGui::EndChild(); + ImGui::Separator(); + uint64_t gold = moneyCopper / 10000; + uint64_t silver = (moneyCopper / 100) % 100; + uint64_t copper = moneyCopper % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", + static_cast(gold), + static_cast(silver), + static_cast(copper)); ImGui::End(); // Draw held item at cursor (on top of everything)