mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-24 16:10:14 +00:00
Emulate server loot/xp and combat feedback in single-player
This commit is contained in:
parent
1383e6c159
commit
3ca8944ced
17 changed files with 830 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class EntityManager;
|
|||
|
||||
struct NpcSpawnDef {
|
||||
std::string mapName;
|
||||
uint32_t entry = 0;
|
||||
std::string name;
|
||||
std::string m2Path;
|
||||
uint32_t level;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "wowee",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue