Emulate server loot/xp and combat feedback in single-player

This commit is contained in:
Kelsi 2026-02-05 14:01:26 -08:00
parent 1383e6c159
commit 3ca8944ced
17 changed files with 830 additions and 29 deletions

View file

@ -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<Sample> splashExitClips;
std::vector<Sample> swimLoopClips;
std::vector<Sample> hardLandClips;
std::vector<Sample> meleeSwingClips;
std::array<SurfaceLandingSet, 7> 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<Sample>& out, const std::vector<std::string>& candidates);

View file

@ -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(uint64_t guid)>;
void setNpcDeathCallback(NpcDeathCallback cb) { npcDeathCallback_ = std::move(cb); }
// Melee swing callback (for driving animation/SFX)
using MeleeSwingCallback = std::function<void()>;
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<uint64_t, LocalLootState> 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;

View file

@ -16,6 +16,7 @@ class EntityManager;
struct NpcSpawnDef {
std::string mapName;
uint32_t entry = 0;
std::string name;
std::string m2Path;
uint32_t level;

View file

@ -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);

View file

@ -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<pipeline::M2Sequence>& out) const;
bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const;
/** Attach a weapon model to a character instance at the given attachment point. */

View file

@ -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;

View file

@ -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; }

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "wowee",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -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<float>(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;

View file

@ -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

View file

@ -5,13 +5,322 @@
#include "core/coordinates.hpp"
#include "core/logger.hpp"
#include <algorithm>
#include <cmath>
#include <cctype>
#include <random>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <unordered_map>
#include <functional>
#include <cstdlib>
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<uint32_t, CreatureTemplateRow> creatureTemplates;
std::unordered_map<uint32_t, std::vector<LootEntryRow>> creatureLoot;
std::unordered_map<uint32_t, std::vector<LootEntryRow>> referenceLoot;
std::unordered_map<uint32_t, ItemTemplateRow> itemTemplates;
};
static std::string trimSql(const std::string& s) {
size_t b = 0;
while (b < s.size() && std::isspace(static_cast<unsigned char>(s[b]))) b++;
size_t e = s.size();
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1]))) e--;
return s.substr(b, e - b);
}
static bool parseInsertTuples(const std::string& line, std::vector<std::string>& 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<std::string> splitCsvTuple(const std::string& tuple) {
std::vector<std::string> 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<std::string> loadCreateTableColumns(const std::filesystem::path& path) {
std::vector<std::string> 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<std::string>& cols, const std::string& name) {
for (size_t i = 0; i < cols.size(); i++) {
if (cols[i] == name) return static_cast<int>(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<void(const std::vector<std::string>&)>& onTuple) {
std::string line;
std::string stmt;
std::vector<std::string> 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<std::string>& row) {
if (idxEntry >= static_cast<int>(row.size())) return;
try {
uint32_t entry = static_cast<uint32_t>(std::stoul(row[idxEntry]));
CreatureTemplateRow tr;
if (idxLoot >= 0 && idxLoot < static_cast<int>(row.size())) {
tr.lootId = static_cast<uint32_t>(std::stoul(row[idxLoot]));
}
if (idxMinGold >= 0 && idxMinGold < static_cast<int>(row.size())) {
tr.minGold = static_cast<uint32_t>(std::stoul(row[idxMinGold]));
}
if (idxMaxGold >= 0 && idxMaxGold < static_cast<int>(row.size())) {
tr.maxGold = static_cast<uint32_t>(std::stoul(row[idxMaxGold]));
}
db.creatureTemplates[entry] = tr;
} catch (const std::exception&) {
}
});
}
}
auto loadLootTable = [&](const std::filesystem::path& path,
std::unordered_map<uint32_t, std::vector<LootEntryRow>>& out) {
if (!std::filesystem::exists(path)) return;
std::ifstream in(path);
processInsertStatements(in, [&](const std::vector<std::string>& row) {
if (row.size() < 7) return;
try {
uint32_t entry = static_cast<uint32_t>(std::stoul(row[0]));
LootEntryRow lr;
lr.item = static_cast<uint32_t>(std::stoul(row[1]));
lr.chance = std::stof(row[2]);
lr.lootmode = static_cast<uint16_t>(std::stoul(row[3]));
lr.groupid = static_cast<uint8_t>(std::stoul(row[4]));
lr.mincountOrRef = static_cast<int32_t>(std::stol(row[5]));
lr.maxcount = static_cast<uint8_t>(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<std::string>& row) {
if (idxEntry >= static_cast<int>(row.size())) return;
try {
ItemTemplateRow ir;
ir.itemId = static_cast<uint32_t>(std::stoul(row[idxEntry]));
if (idxName >= 0 && idxName < static_cast<int>(row.size())) {
ir.name = unquoteSqlString(row[idxName]);
}
if (idxDisplay >= 0 && idxDisplay < static_cast<int>(row.size())) {
ir.displayId = static_cast<uint32_t>(std::stoul(row[idxDisplay]));
}
if (idxQuality >= 0 && idxQuality < static_cast<int>(row.size())) {
ir.quality = static_cast<uint8_t>(std::stoul(row[idxQuality]));
}
if (idxInvType >= 0 && idxInvType < static_cast<int>(row.size())) {
ir.inventoryType = static_cast<uint8_t>(std::stoul(row[idxInvType]));
}
if (idxStack >= 0 && idxStack < static_cast<int>(row.size())) {
ir.maxStack = static_cast<int32_t>(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<Unit>(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<ItemQuality>(itTpl->second.quality);
def.inventoryType = itTpl->second.inventoryType;
def.maxStack = std::max(def.maxStack, static_cast<uint32_t>(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<Unit>(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<Unit>(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<uint32_t>(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<int32_t>(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<Unit>(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<uint32_t> 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<uint8_t, std::vector<LootEntryRow>> groups;
std::vector<LootEntryRow> 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<float> roll(0.0f, 100.0f);
auto addItem = [&](uint32_t itemId, uint32_t count) {
LootItem li;
li.slotIndex = static_cast<uint8_t>(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<void(const std::vector<LootEntryRow>&, bool)> processLootTable;
processLootTable = [&](const std::vector<LootEntryRow>& 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<float>(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<uint32_t>(-row.mincountOrRef));
if (refIt != db.referenceLoot.end()) {
processLootTable(refIt->second, false);
}
} else {
uint32_t minc = static_cast<uint32_t>(std::max(1, row.mincountOrRef));
uint32_t maxc = std::max(minc, static_cast<uint32_t>(row.maxcount));
std::uniform_int_distribution<uint32_t> 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<uint32_t>(-row.mincountOrRef));
if (refIt != db.referenceLoot.end()) {
processLootTable(refIt->second, false);
}
continue;
}
uint32_t minc = static_cast<uint32_t>(std::max(1, row.mincountOrRef));
uint32_t maxc = std::max(minc, static_cast<uint32_t>(row.maxcount));
std::uniform_int_distribution<uint32_t> 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<uint16_t>(Opcode::SMSG_LOOT_RESPONSE));
packet.writeUInt64(data.lootGuid);
packet.writeUInt8(data.lootType);
packet.writeUInt32(data.gold);
packet.writeUInt8(static_cast<uint8_t>(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<uint16_t>(Opcode::SMSG_LOOT_RELEASE_RESPONSE));
handleLootReleaseResponse(packet);
currentLoot = LootResponseData{};
}
void GameHandler::simulateLootRemove(uint8_t slotIndex) {
if (!lootWindowOpen) return;
network::Packet packet(static_cast<uint16_t>(Opcode::SMSG_LOOT_REMOVED));
packet.writeUInt8(slotIndex);
handleLootRemoved(packet);
}
void GameHandler::simulateXpGain(uint64_t victimGuid, uint32_t totalXp) {
network::Packet packet(static_cast<uint16_t>(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;

View file

@ -594,19 +594,21 @@ std::vector<NpcSpawnDef> 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);

View file

@ -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<uint8_t>(c));

View file

@ -1404,6 +1404,22 @@ bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId)
return false;
}
bool CharacterRenderer::getAnimationSequences(uint32_t instanceId, std::vector<pipeline::M2Sequence>& 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()) {

View file

@ -18,6 +18,8 @@
#include "rendering/m2_renderer.hpp"
#include "rendering/minimap.hpp"
#include "rendering/shader.hpp"
#include "pipeline/m2_loader.hpp"
#include <algorithm>
#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<pipeline::M2Sequence> 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<float>(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<float>(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;

View file

@ -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());

View file

@ -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<unsigned long long>(gold),
static_cast<unsigned long long>(silver),
static_cast<unsigned long long>(copper));
ImGui::End();
// Draw held item at cursor (on top of everything)