mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Restructure inventory UI, add vendor selling, camera intro on all spawns, and quest log
Split inventory into bags-only (B key) and character screen (C key). Vendor window auto-opens bags with sell prices on hover and right-click to sell. Add camera intro pan on all login/spawn/teleport/hearthstone events and idle orbit after 2 minutes. Add quest log UI, SMSG_MONSTER_MOVE handling, deferred creature spawn queue, and creature fade-in/movement interpolation for online mode.
This commit is contained in:
parent
a4a39c7f0f
commit
7128ea1417
21 changed files with 1092 additions and 149 deletions
|
|
@ -152,6 +152,7 @@ set(WOWEE_SOURCES
|
|||
src/ui/character_screen.cpp
|
||||
src/ui/game_screen.cpp
|
||||
src/ui/inventory_screen.cpp
|
||||
src/ui/quest_log_screen.cpp
|
||||
src/ui/spellbook_screen.cpp
|
||||
|
||||
# Main
|
||||
|
|
|
|||
|
|
@ -153,8 +153,19 @@ private:
|
|||
std::unordered_map<uint32_t, FacialHairGeosets> facialHairGeosetMap_;
|
||||
std::unordered_map<uint64_t, uint32_t> creatureInstances_; // guid → render instanceId
|
||||
std::unordered_map<uint64_t, uint32_t> creatureModelIds_; // guid → loaded modelId
|
||||
std::unordered_map<uint32_t, uint32_t> displayIdModelCache_; // displayId → modelId (model caching)
|
||||
uint32_t nextCreatureModelId_ = 5000; // Model IDs for online creatures
|
||||
bool creatureLookupsBuilt_ = false;
|
||||
|
||||
// Deferred creature spawn queue (throttles spawning to avoid hangs)
|
||||
struct PendingCreatureSpawn {
|
||||
uint64_t guid;
|
||||
uint32_t displayId;
|
||||
float x, y, z, orientation;
|
||||
};
|
||||
std::vector<PendingCreatureSpawn> pendingCreatureSpawns_;
|
||||
static constexpr int MAX_SPAWNS_PER_FRAME = 2;
|
||||
void processCreatureSpawnQueue();
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
|
|
|||
|
|
@ -294,6 +294,11 @@ public:
|
|||
using CreatureDespawnCallback = std::function<void(uint64_t guid)>;
|
||||
void setCreatureDespawnCallback(CreatureDespawnCallback cb) { creatureDespawnCallback_ = std::move(cb); }
|
||||
|
||||
// Creature move callback (online mode - triggered by SMSG_MONSTER_MOVE)
|
||||
// Parameters: guid, x, y, z (canonical), duration_ms (0 = instant)
|
||||
using CreatureMoveCallback = std::function<void(uint64_t guid, float x, float y, float z, uint32_t durationMs)>;
|
||||
void setCreatureMoveCallback(CreatureMoveCallback cb) { creatureMoveCallback_ = std::move(cb); }
|
||||
|
||||
// Cooldowns
|
||||
float getSpellCooldown(uint32_t spellId) const;
|
||||
|
||||
|
|
@ -330,17 +335,33 @@ public:
|
|||
bool isQuestDetailsOpen() const { return questDetailsOpen; }
|
||||
const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; }
|
||||
|
||||
// Quest log
|
||||
struct QuestLogEntry {
|
||||
uint32_t questId = 0;
|
||||
std::string title;
|
||||
std::string objectives;
|
||||
bool complete = false;
|
||||
};
|
||||
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
|
||||
void abandonQuest(uint32_t questId);
|
||||
|
||||
// Vendor
|
||||
void openVendor(uint64_t npcGuid);
|
||||
void closeVendor();
|
||||
void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count);
|
||||
void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count);
|
||||
void sellItemBySlot(int backpackIndex);
|
||||
bool isVendorWindowOpen() const { return vendorWindowOpen; }
|
||||
const ListInventoryData& getVendorItems() const { return currentVendorItems; }
|
||||
const ItemQueryResponseData* getItemInfo(uint32_t itemId) const {
|
||||
auto it = itemInfoCache_.find(itemId);
|
||||
return (it != itemInfoCache_.end()) ? &it->second : nullptr;
|
||||
}
|
||||
uint64_t getBackpackItemGuid(int index) const {
|
||||
if (index < 0 || index >= static_cast<int>(backpackSlotGuids_.size())) return 0;
|
||||
return backpackSlotGuids_[index];
|
||||
}
|
||||
uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; }
|
||||
|
||||
/**
|
||||
* Set callbacks
|
||||
|
|
@ -464,6 +485,9 @@ private:
|
|||
// ---- XP handler ----
|
||||
void handleXpGain(network::Packet& packet);
|
||||
|
||||
// ---- Creature movement handler ----
|
||||
void handleMonsterMove(network::Packet& packet);
|
||||
|
||||
// ---- Phase 5 handlers ----
|
||||
void handleLootResponse(network::Packet& packet);
|
||||
void handleLootReleaseResponse(network::Packet& packet);
|
||||
|
|
@ -580,6 +604,7 @@ private:
|
|||
WorldEntryCallback worldEntryCallback_;
|
||||
CreatureSpawnCallback creatureSpawnCallback_;
|
||||
CreatureDespawnCallback creatureDespawnCallback_;
|
||||
CreatureMoveCallback creatureMoveCallback_;
|
||||
std::vector<uint32_t> knownSpells;
|
||||
std::unordered_map<uint32_t, float> spellCooldowns; // spellId -> remaining seconds
|
||||
uint8_t castCount = 0;
|
||||
|
|
@ -616,6 +641,9 @@ private:
|
|||
bool questDetailsOpen = false;
|
||||
QuestDetailsData currentQuestDetails;
|
||||
|
||||
// Quest log
|
||||
std::vector<QuestLogEntry> questLog_;
|
||||
|
||||
// Vendor
|
||||
bool vendorWindowOpen = false;
|
||||
ListInventoryData currentVendorItems;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ enum class Opcode : uint16_t {
|
|||
// ---- XP ----
|
||||
SMSG_LOG_XPGAIN = 0x1D0,
|
||||
|
||||
// ---- Creature Movement ----
|
||||
SMSG_MONSTER_MOVE = 0x0DD,
|
||||
|
||||
// ---- Phase 2: Combat Core ----
|
||||
CMSG_ATTACKSWING = 0x141,
|
||||
CMSG_ATTACKSTOP = 0x142,
|
||||
|
|
@ -146,6 +149,7 @@ enum class Opcode : uint16_t {
|
|||
SMSG_QUESTGIVER_OFFER_REWARD = 0x18D,
|
||||
CMSG_QUESTGIVER_CHOOSE_REWARD = 0x18E,
|
||||
SMSG_QUESTGIVER_QUEST_COMPLETE = 0x191,
|
||||
CMSG_QUESTLOG_REMOVE_QUEST = 0x194,
|
||||
|
||||
// ---- Phase 5: Vendor ----
|
||||
CMSG_LIST_INVENTORY = 0x19E,
|
||||
|
|
|
|||
|
|
@ -740,6 +740,7 @@ struct ItemQueryResponseData {
|
|||
int32_t agility = 0;
|
||||
int32_t intellect = 0;
|
||||
int32_t spirit = 0;
|
||||
uint32_t sellPrice = 0;
|
||||
std::string subclassName;
|
||||
bool valid = false;
|
||||
};
|
||||
|
|
@ -754,6 +755,25 @@ public:
|
|||
// Phase 2: Combat Core
|
||||
// ============================================================
|
||||
|
||||
/** SMSG_MONSTER_MOVE data */
|
||||
struct MonsterMoveData {
|
||||
uint64_t guid = 0;
|
||||
float x = 0, y = 0, z = 0; // Current position (server coords)
|
||||
uint8_t moveType = 0; // 0=Normal, 1=Stop, 2=FacingSpot, 3=FacingTarget, 4=FacingAngle
|
||||
float facingAngle = 0;
|
||||
uint64_t facingTarget = 0;
|
||||
uint32_t splineFlags = 0;
|
||||
uint32_t duration = 0;
|
||||
// Destination (final point of the spline, server coords)
|
||||
float destX = 0, destY = 0, destZ = 0;
|
||||
bool hasDest = false;
|
||||
};
|
||||
|
||||
class MonsterMoveParser {
|
||||
public:
|
||||
static bool parse(network::Packet& packet, MonsterMoveData& data);
|
||||
};
|
||||
|
||||
/** CMSG_ATTACKSWING packet builder */
|
||||
class AttackSwingPacket {
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -192,6 +192,11 @@ private:
|
|||
float introEndPitch = -5.0f;
|
||||
float introStartDistance = 12.0f;
|
||||
float introEndDistance = 10.0f;
|
||||
|
||||
// Idle camera: triggers intro pan after IDLE_TIMEOUT seconds of no input
|
||||
float idleTimer_ = 0.0f;
|
||||
bool idleOrbit_ = false; // true when current intro pan is an idle orbit (loops)
|
||||
static constexpr float IDLE_TIMEOUT = 120.0f; // 2 minutes
|
||||
};
|
||||
|
||||
} // namespace rendering
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ public:
|
|||
|
||||
void setInstancePosition(uint32_t instanceId, const glm::vec3& position);
|
||||
void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation);
|
||||
void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds);
|
||||
void startFadeIn(uint32_t instanceId, float durationSeconds);
|
||||
const pipeline::M2Model* getModelData(uint32_t modelId) const;
|
||||
void setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets);
|
||||
void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId);
|
||||
void setInstanceVisible(uint32_t instanceId, bool visible);
|
||||
|
|
@ -130,6 +133,18 @@ private:
|
|||
// Weapon attachments (weapons parented to this instance's bones)
|
||||
std::vector<WeaponAttachment> weaponAttachments;
|
||||
|
||||
// Opacity (for fade-in)
|
||||
float opacity = 1.0f;
|
||||
float fadeInTime = 0.0f; // elapsed fade time (seconds)
|
||||
float fadeInDuration = 0.0f; // total fade duration (0 = no fade)
|
||||
|
||||
// Movement interpolation
|
||||
bool isMoving = false;
|
||||
glm::vec3 moveStart{0.0f};
|
||||
glm::vec3 moveEnd{0.0f};
|
||||
float moveDuration = 0.0f; // seconds
|
||||
float moveElapsed = 0.0f;
|
||||
|
||||
// Override model matrix (used for weapon instances positioned by parent bone)
|
||||
bool hasOverrideModelMatrix = false;
|
||||
glm::mat4 overrideModelMatrix{1.0f};
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ public:
|
|||
bool isMoving() const;
|
||||
void triggerMeleeSwing();
|
||||
|
||||
// Selection circle for targeted entity
|
||||
void setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color);
|
||||
void clearSelectionCircle();
|
||||
|
||||
// CPU timing stats (milliseconds, last frame).
|
||||
double getLastUpdateMs() const { return lastUpdateMs; }
|
||||
double getLastRenderMs() const { return lastRenderMs; }
|
||||
|
|
@ -224,6 +228,18 @@ private:
|
|||
// Target facing
|
||||
const glm::vec3* targetPosition = nullptr;
|
||||
|
||||
// Selection circle rendering
|
||||
uint32_t selCircleVAO = 0;
|
||||
uint32_t selCircleVBO = 0;
|
||||
uint32_t selCircleShader = 0;
|
||||
int selCircleVertCount = 0;
|
||||
void initSelectionCircle();
|
||||
void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection);
|
||||
glm::vec3 selCirclePos{0.0f};
|
||||
glm::vec3 selCircleColor{1.0f, 0.0f, 0.0f};
|
||||
float selCircleRadius = 1.5f;
|
||||
bool selCircleVisible = false;
|
||||
|
||||
// Footstep event tracking (animation-driven)
|
||||
uint32_t footstepLastAnimationId = 0;
|
||||
float footstepLastNormTime = 0.0f;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "game/inventory.hpp"
|
||||
#include "rendering/world_map.hpp"
|
||||
#include "ui/inventory_screen.hpp"
|
||||
#include "ui/quest_log_screen.hpp"
|
||||
#include "ui/spellbook_screen.hpp"
|
||||
#include <imgui.h>
|
||||
#include <string>
|
||||
|
|
@ -143,6 +144,7 @@ private:
|
|||
void renderWorldMap(game::GameHandler& gameHandler);
|
||||
|
||||
InventoryScreen inventoryScreen;
|
||||
QuestLogScreen questLogScreen;
|
||||
SpellbookScreen spellbookScreen;
|
||||
rendering::WorldMap worldMap;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,36 @@
|
|||
#pragma once
|
||||
|
||||
#include "game/inventory.hpp"
|
||||
#include "game/world_packets.hpp"
|
||||
#include <imgui.h>
|
||||
#include <functional>
|
||||
|
||||
namespace wowee {
|
||||
namespace game { class GameHandler; }
|
||||
namespace ui {
|
||||
|
||||
class InventoryScreen {
|
||||
public:
|
||||
/// Render bags window (B key). Positioned at bottom of screen.
|
||||
void render(game::Inventory& inventory, uint64_t moneyCopper);
|
||||
|
||||
/// Render character screen (C key). Standalone equipment window.
|
||||
void renderCharacterScreen(game::Inventory& inventory);
|
||||
|
||||
bool isOpen() const { return open; }
|
||||
void toggle() { open = !open; }
|
||||
void setOpen(bool o) { open = o; }
|
||||
|
||||
bool isCharacterOpen() const { return characterOpen; }
|
||||
void toggleCharacter() { characterOpen = !characterOpen; }
|
||||
void setCharacterOpen(bool o) { characterOpen = o; }
|
||||
|
||||
/// Enable vendor mode: right-clicking bag items sells them.
|
||||
void setVendorMode(bool enabled, game::GameHandler* handler) {
|
||||
vendorMode_ = enabled;
|
||||
gameHandler_ = handler;
|
||||
}
|
||||
|
||||
/// Returns true if equipment changed since last call, and clears the flag.
|
||||
bool consumeEquipmentDirty() { bool d = equipmentDirty; equipmentDirty = false; return d; }
|
||||
/// Returns true if any inventory slot changed since last call, and clears the flag.
|
||||
|
|
@ -20,10 +38,16 @@ public:
|
|||
|
||||
private:
|
||||
bool open = false;
|
||||
bool characterOpen = false;
|
||||
bool bKeyWasDown = false;
|
||||
bool cKeyWasDown = false;
|
||||
bool equipmentDirty = false;
|
||||
bool inventoryDirty = false;
|
||||
|
||||
// Vendor sell mode
|
||||
bool vendorMode_ = false;
|
||||
game::GameHandler* gameHandler_ = nullptr;
|
||||
|
||||
// Drag-and-drop held item state
|
||||
bool holdingItem = false;
|
||||
game::ItemDef heldItem;
|
||||
|
|
|
|||
21
include/ui/quest_log_screen.hpp
Normal file
21
include/ui/quest_log_screen.hpp
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
|
||||
#include "game/game_handler.hpp"
|
||||
#include <imgui.h>
|
||||
|
||||
namespace wowee { namespace ui {
|
||||
|
||||
class QuestLogScreen {
|
||||
public:
|
||||
void render(game::GameHandler& gameHandler);
|
||||
bool isOpen() const { return open; }
|
||||
void toggle() { open = !open; }
|
||||
void setOpen(bool o) { open = o; }
|
||||
|
||||
private:
|
||||
bool open = false;
|
||||
bool lKeyWasDown = false;
|
||||
int selectedIndex = -1;
|
||||
};
|
||||
|
||||
}} // namespace wowee::ui
|
||||
|
|
@ -444,6 +444,8 @@ void Application::update(float deltaTime) {
|
|||
if (!npcsSpawned && singlePlayerMode) {
|
||||
spawnNpcs();
|
||||
}
|
||||
// Process deferred online creature spawns (throttled)
|
||||
processCreatureSpawnQueue();
|
||||
if (npcManager && renderer && renderer->getCharacterRenderer()) {
|
||||
npcManager->update(deltaTime, renderer->getCharacterRenderer());
|
||||
}
|
||||
|
|
@ -626,7 +628,8 @@ void Application::setupUICallbacks() {
|
|||
|
||||
// Creature spawn callback (online mode) - spawn creature models
|
||||
gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) {
|
||||
spawnOnlineCreature(guid, displayId, x, y, z, orientation);
|
||||
// Queue spawns to avoid hanging when many creatures appear at once
|
||||
pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation});
|
||||
});
|
||||
|
||||
// Creature despawn callback (online mode) - remove creature models
|
||||
|
|
@ -634,6 +637,32 @@ void Application::setupUICallbacks() {
|
|||
despawnOnlineCreature(guid);
|
||||
});
|
||||
|
||||
// Creature move callback (online mode) - update creature positions
|
||||
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
|
||||
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
|
||||
float durationSec = static_cast<float>(durationMs) / 1000.0f;
|
||||
renderer->getCharacterRenderer()->moveInstanceTo(it->second, renderPos, durationSec);
|
||||
}
|
||||
});
|
||||
|
||||
// NPC death callback (online mode) - play death animation
|
||||
gameHandler->setNpcDeathCallback([this](uint64_t guid) {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
|
||||
renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death
|
||||
}
|
||||
});
|
||||
|
||||
// NPC swing callback (online mode) - play attack animation
|
||||
gameHandler->setNpcSwingCallback([this](uint64_t guid) {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
|
||||
renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack
|
||||
}
|
||||
});
|
||||
|
||||
// "Create Character" button on character screen
|
||||
uiManager->getCharacterScreen().setOnCreateCharacter([this]() {
|
||||
uiManager->getCharacterCreateScreen().reset();
|
||||
|
|
@ -1415,6 +1444,7 @@ void Application::startSinglePlayer() {
|
|||
// snap the third-person camera into the correct orbit position.
|
||||
if (spawnSnapToGround && renderer && renderer->getCameraController()) {
|
||||
renderer->getCameraController()->reset();
|
||||
renderer->getCameraController()->startIntroPan(2.8f, 140.0f);
|
||||
}
|
||||
|
||||
if (loadingScreenOk) {
|
||||
|
|
@ -1426,6 +1456,7 @@ void Application::startSinglePlayer() {
|
|||
auto* camCtrl = renderer->getCameraController();
|
||||
gameHandler->setHearthstoneCallback([camCtrl]() {
|
||||
camCtrl->reset();
|
||||
camCtrl->startIntroPan(2.8f, 140.0f);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1549,6 +1580,7 @@ void Application::teleportTo(int presetIndex) {
|
|||
// Floor-snapping presets use camera reset. WMO-floor presets keep explicit Z.
|
||||
if (spawnSnapToGround && renderer && renderer->getCameraController()) {
|
||||
renderer->getCameraController()->reset();
|
||||
renderer->getCameraController()->startIntroPan(2.8f, 140.0f);
|
||||
}
|
||||
|
||||
if (!spawnSnapToGround && renderer) {
|
||||
|
|
@ -1592,6 +1624,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
if (renderer->getCameraController()) {
|
||||
renderer->getCameraController()->setDefaultSpawn(spawnRender, 0.0f, 15.0f);
|
||||
renderer->getCameraController()->reset();
|
||||
renderer->getCameraController()->startIntroPan(2.8f, 140.0f);
|
||||
}
|
||||
|
||||
// Set map name for WMO renderer
|
||||
|
|
@ -1802,50 +1835,61 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
|
||||
auto* charRenderer = renderer->getCharacterRenderer();
|
||||
|
||||
// Load model if not already loaded for this displayId
|
||||
uint32_t modelId = nextCreatureModelId_++;
|
||||
// Check model cache - reuse if same displayId was already loaded
|
||||
uint32_t modelId = 0;
|
||||
bool modelCached = false;
|
||||
auto cacheIt = displayIdModelCache_.find(displayId);
|
||||
if (cacheIt != displayIdModelCache_.end()) {
|
||||
modelId = cacheIt->second;
|
||||
modelCached = true;
|
||||
} else {
|
||||
// Load model from disk (only once per displayId)
|
||||
modelId = nextCreatureModelId_++;
|
||||
|
||||
auto m2Data = assetManager->readFile(m2Path);
|
||||
if (m2Data.empty()) {
|
||||
LOG_WARNING("Failed to read creature M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
auto m2Data = assetManager->readFile(m2Path);
|
||||
if (m2Data.empty()) {
|
||||
LOG_WARNING("Failed to read creature M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
||||
if (model.vertices.empty()) {
|
||||
LOG_WARNING("Failed to parse creature M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
||||
if (model.vertices.empty()) {
|
||||
LOG_WARNING("Failed to parse creature M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load skin file
|
||||
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
|
||||
auto skinData = assetManager->readFile(skinPath);
|
||||
if (!skinData.empty()) {
|
||||
pipeline::M2Loader::loadSkin(skinData, model);
|
||||
}
|
||||
// Load skin file
|
||||
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
|
||||
auto skinData = assetManager->readFile(skinPath);
|
||||
if (!skinData.empty()) {
|
||||
pipeline::M2Loader::loadSkin(skinData, model);
|
||||
}
|
||||
|
||||
// Load external .anim files for sequences without flag 0x20
|
||||
std::string basePath = m2Path.substr(0, m2Path.size() - 3);
|
||||
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
||||
if (!(model.sequences[si].flags & 0x20)) {
|
||||
char animFileName[256];
|
||||
snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim",
|
||||
basePath.c_str(), model.sequences[si].id, model.sequences[si].variationIndex);
|
||||
auto animData = assetManager->readFile(animFileName);
|
||||
if (!animData.empty()) {
|
||||
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model);
|
||||
// Load external .anim files for sequences without flag 0x20
|
||||
std::string basePath = m2Path.substr(0, m2Path.size() - 3);
|
||||
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
||||
if (!(model.sequences[si].flags & 0x20)) {
|
||||
char animFileName[256];
|
||||
snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim",
|
||||
basePath.c_str(), model.sequences[si].id, model.sequences[si].variationIndex);
|
||||
auto animData = assetManager->readFile(animFileName);
|
||||
if (!animData.empty()) {
|
||||
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!charRenderer->loadModel(model, modelId)) {
|
||||
LOG_WARNING("Failed to load creature model: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
displayIdModelCache_[displayId] = modelId;
|
||||
}
|
||||
|
||||
if (!charRenderer->loadModel(model, modelId)) {
|
||||
LOG_WARNING("Failed to load creature model: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply skin textures from CreatureDisplayInfo.dbc
|
||||
// Apply skin textures from CreatureDisplayInfo.dbc (only for newly loaded models)
|
||||
auto itDisplayData = displayDataMap_.find(displayId);
|
||||
if (itDisplayData != displayDataMap_.end()) {
|
||||
if (!modelCached && itDisplayData != displayDataMap_.end()) {
|
||||
const auto& dispData = itDisplayData->second;
|
||||
|
||||
// Get model directory for texture path construction
|
||||
|
|
@ -1858,9 +1902,17 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
LOG_DEBUG("DisplayId ", displayId, " skins: '", dispData.skin1, "', '", dispData.skin2, "', '", dispData.skin3,
|
||||
"' extraDisplayId=", dispData.extraDisplayId);
|
||||
|
||||
// Get model data from CharacterRenderer for texture iteration
|
||||
const auto* modelData = charRenderer->getModelData(modelId);
|
||||
if (!modelData) {
|
||||
LOG_WARNING("Model data not found for modelId ", modelId);
|
||||
}
|
||||
|
||||
// Log texture types in the model
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
LOG_DEBUG(" Model texture ", ti, ": type=", model.textures[ti].type, " filename='", model.textures[ti].filename, "'");
|
||||
if (modelData) {
|
||||
for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
|
||||
LOG_DEBUG(" Model texture ", ti, ": type=", modelData->textures[ti].type, " filename='", modelData->textures[ti].filename, "'");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a humanoid NPC with extra display info
|
||||
|
|
@ -1885,9 +1937,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName;
|
||||
GLuint finalTex = charRenderer->loadTexture(bakePath);
|
||||
|
||||
if (finalTex != 0) {
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
uint32_t texType = model.textures[ti].type;
|
||||
if (finalTex != 0 && modelData) {
|
||||
for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
|
||||
uint32_t texType = modelData->textures[ti].type;
|
||||
if (texType == 1 || texType == 2) {
|
||||
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), finalTex);
|
||||
LOG_DEBUG("Applied baked NPC texture to slot ", ti, " (type ", texType, "): ", bakePath);
|
||||
|
|
@ -1926,9 +1978,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
|
||||
if (!hairTexPath.empty()) {
|
||||
GLuint hairTex = charRenderer->loadTexture(hairTexPath);
|
||||
if (hairTex != 0) {
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
if (model.textures[ti].type == 6) {
|
||||
if (hairTex != 0 && modelData) {
|
||||
for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
|
||||
if (modelData->textures[ti].type == 6) {
|
||||
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), hairTex);
|
||||
LOG_DEBUG("Applied hair texture to slot ", ti, ": ", hairTexPath);
|
||||
}
|
||||
|
|
@ -1942,9 +1994,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
}
|
||||
|
||||
// Apply creature skin textures (for non-humanoid creatures)
|
||||
if (!hasHumanoidTexture) {
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
const auto& tex = model.textures[ti];
|
||||
if (!hasHumanoidTexture && modelData) {
|
||||
for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
|
||||
const auto& tex = modelData->textures[ti];
|
||||
std::string skinPath;
|
||||
|
||||
// Creature skin types: 11 = skin1, 12 = skin2, 13 = skin3
|
||||
|
|
@ -2113,9 +2165,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
}
|
||||
|
||||
// Log model's actual submesh IDs for debugging geoset mismatches
|
||||
{
|
||||
if (auto* md = charRenderer->getModelData(modelId)) {
|
||||
std::string batchIds;
|
||||
for (const auto& b : model.batches) {
|
||||
for (const auto& b : md->batches) {
|
||||
if (!batchIds.empty()) batchIds += ",";
|
||||
batchIds += std::to_string(b.submeshId);
|
||||
}
|
||||
|
|
@ -2210,8 +2262,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
}
|
||||
}
|
||||
|
||||
// Play idle animation
|
||||
// Play idle animation and fade in
|
||||
charRenderer->playAnimation(instanceId, 0, true);
|
||||
charRenderer->startFadeIn(instanceId, 0.5f);
|
||||
|
||||
// Track instance
|
||||
creatureInstances_[guid] = instanceId;
|
||||
|
|
@ -2221,6 +2274,18 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
" displayId=", displayId, " at (", x, ", ", y, ", ", z, ")");
|
||||
}
|
||||
|
||||
void Application::processCreatureSpawnQueue() {
|
||||
if (pendingCreatureSpawns_.empty()) return;
|
||||
|
||||
int spawned = 0;
|
||||
while (!pendingCreatureSpawns_.empty() && spawned < MAX_SPAWNS_PER_FRAME) {
|
||||
auto& s = pendingCreatureSpawns_.front();
|
||||
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation);
|
||||
pendingCreatureSpawns_.erase(pendingCreatureSpawns_.begin());
|
||||
spawned++;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::despawnOnlineCreature(uint64_t guid) {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it == creatureInstances_.end()) return;
|
||||
|
|
|
|||
|
|
@ -840,6 +840,28 @@ void GameHandler::update(float deltaTime) {
|
|||
updateLocalCombat(deltaTime);
|
||||
updateNpcAggro(deltaTime);
|
||||
}
|
||||
|
||||
// Online mode: maintain auto-attack by periodically re-sending CMSG_ATTACKSWING
|
||||
if (!singlePlayerMode_ && autoAttacking && autoAttackTarget != 0 && socket) {
|
||||
auto target = entityManager.getEntity(autoAttackTarget);
|
||||
if (!target) {
|
||||
// Target gone
|
||||
stopAutoAttack();
|
||||
} else if (target->getType() == ObjectType::UNIT) {
|
||||
auto unit = std::static_pointer_cast<Unit>(target);
|
||||
if (unit->getHealth() == 0) {
|
||||
stopAutoAttack();
|
||||
} else {
|
||||
// Re-send attack swing every 2 seconds to keep server combat alive
|
||||
swingTimer_ += deltaTime;
|
||||
if (swingTimer_ >= 2.0f) {
|
||||
auto pkt = AttackSwingPacket::build(autoAttackTarget);
|
||||
socket->send(pkt);
|
||||
swingTimer_ = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (singlePlayerMode_) {
|
||||
|
|
@ -982,6 +1004,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
handleXpGain(packet);
|
||||
break;
|
||||
|
||||
// ---- Creature Movement ----
|
||||
case Opcode::SMSG_MONSTER_MOVE:
|
||||
handleMonsterMove(packet);
|
||||
break;
|
||||
|
||||
// ---- Phase 2: Combat ----
|
||||
case Opcode::SMSG_ATTACKSTART:
|
||||
handleAttackStart(packet);
|
||||
|
|
@ -1082,7 +1109,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_PERIODICAURALOG:
|
||||
case Opcode::SMSG_SPELLENERGIZELOG:
|
||||
case Opcode::SMSG_ENVIRONMENTALDAMAGELOG:
|
||||
case Opcode::SMSG_LOOT_MONEY_NOTIFY:
|
||||
case Opcode::SMSG_LOOT_MONEY_NOTIFY: {
|
||||
// uint32 money + uint8 soleLooter
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t amount = packet.readUInt32();
|
||||
playerMoneyCopper_ += amount;
|
||||
LOG_INFO("Looted ", amount, " copper (total: ", playerMoneyCopper_, ")");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_LOOT_CLEAR_MONEY:
|
||||
case Opcode::SMSG_NPC_TEXT_UPDATE:
|
||||
case Opcode::SMSG_SELL_ITEM:
|
||||
|
|
@ -1091,12 +1126,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE:
|
||||
case Opcode::MSG_RAID_TARGET_UPDATE:
|
||||
case Opcode::SMSG_QUESTGIVER_STATUS:
|
||||
LOG_DEBUG("Ignoring SMSG_QUESTGIVER_STATUS");
|
||||
break;
|
||||
case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS:
|
||||
handleQuestDetails(packet);
|
||||
break;
|
||||
case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: {
|
||||
// Mark quest as complete in local log
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t questId = packet.readUInt32();
|
||||
for (auto& q : questLog_) {
|
||||
if (q.questId == questId) {
|
||||
q.complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS:
|
||||
case Opcode::SMSG_QUESTGIVER_OFFER_REWARD:
|
||||
case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE:
|
||||
case Opcode::SMSG_GROUP_SET_LEADER:
|
||||
LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec);
|
||||
break;
|
||||
|
|
@ -2478,7 +2527,12 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
for (const auto& [key, val] : block.fields) {
|
||||
if (key == 634) { playerXp_ = val; } // PLAYER_XP
|
||||
else if (key == 635) { playerNextLevelXp_ = val; } // PLAYER_NEXT_LEVEL_XP
|
||||
else if (key == 54) { serverPlayerLevel_ = val; } // UNIT_FIELD_LEVEL
|
||||
else if (key == 54) {
|
||||
serverPlayerLevel_ = val; // UNIT_FIELD_LEVEL
|
||||
for (auto& ch : characters) {
|
||||
if (ch.guid == playerGuid) { ch.level = val; break; }
|
||||
}
|
||||
}
|
||||
else if (key == 632) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE
|
||||
else if (key >= 322 && key <= 367) {
|
||||
// PLAYER_FIELD_INV_SLOT_HEAD: equipment slots (23 slots × 2 fields)
|
||||
|
|
@ -2522,8 +2576,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
switch (key) {
|
||||
case 24:
|
||||
unit->setHealth(val);
|
||||
if (val == 0 && block.guid == autoAttackTarget) {
|
||||
stopAutoAttack();
|
||||
if (val == 0) {
|
||||
if (block.guid == autoAttackTarget) {
|
||||
stopAutoAttack();
|
||||
}
|
||||
// Trigger death animation for NPC units
|
||||
if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) {
|
||||
npcDeathCallback_(block.guid);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 25: unit->setPower(val); break;
|
||||
|
|
@ -2540,10 +2600,29 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
if (block.guid == playerGuid) {
|
||||
bool slotsChanged = false;
|
||||
for (const auto& [key, val] : block.fields) {
|
||||
if (key == 634) { playerXp_ = val; }
|
||||
else if (key == 635) { playerNextLevelXp_ = val; }
|
||||
else if (key == 54) { serverPlayerLevel_ = val; }
|
||||
else if (key == 632) { playerMoneyCopper_ = val; }
|
||||
if (key == 634) {
|
||||
playerXp_ = val;
|
||||
LOG_INFO("XP updated: ", val);
|
||||
}
|
||||
else if (key == 635) {
|
||||
playerNextLevelXp_ = val;
|
||||
LOG_INFO("Next level XP updated: ", val);
|
||||
}
|
||||
else if (key == 54) {
|
||||
serverPlayerLevel_ = val;
|
||||
LOG_INFO("Level updated: ", val);
|
||||
// Update Character struct for character selection screen
|
||||
for (auto& ch : characters) {
|
||||
if (ch.guid == playerGuid) {
|
||||
ch.level = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (key == 632) {
|
||||
playerMoneyCopper_ = val;
|
||||
LOG_INFO("Money updated via VALUES: ", val, " copper");
|
||||
}
|
||||
else if (key >= 322 && key <= 367) {
|
||||
int slotIndex = (key - 322) / 2;
|
||||
bool isLow = ((key - 322) % 2 == 0);
|
||||
|
|
@ -2795,6 +2874,7 @@ void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
|
|||
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
||||
auto t = entity->getType();
|
||||
if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue;
|
||||
if (guid == playerGuid) continue; // Don't tab-target self
|
||||
float dx = entity->getX() - playerX;
|
||||
float dy = entity->getY() - playerY;
|
||||
float dz = entity->getZ() - playerZ;
|
||||
|
|
@ -3063,9 +3143,75 @@ void GameHandler::handleAttackStop(network::Packet& packet) {
|
|||
AttackStopData data;
|
||||
if (!AttackStopParser::parse(packet, data)) return;
|
||||
|
||||
// Don't clear autoAttacking on SMSG_ATTACKSTOP - the server sends this
|
||||
// when the attack loop pauses (out of range, etc). The player's intent
|
||||
// to attack persists until target dies or player explicitly cancels.
|
||||
// We'll re-send CMSG_ATTACKSWING periodically in the update loop.
|
||||
if (data.attackerGuid == playerGuid) {
|
||||
autoAttacking = false;
|
||||
autoAttackTarget = 0;
|
||||
LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)");
|
||||
}
|
||||
}
|
||||
|
||||
void GameHandler::handleMonsterMove(network::Packet& packet) {
|
||||
MonsterMoveData data;
|
||||
if (!MonsterMoveParser::parse(packet, data)) {
|
||||
LOG_WARNING("Failed to parse SMSG_MONSTER_MOVE");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update entity position in entity manager
|
||||
auto entity = entityManager.getEntity(data.guid);
|
||||
if (entity) {
|
||||
if (data.hasDest) {
|
||||
// Convert destination from server to canonical coords
|
||||
glm::vec3 destCanonical = core::coords::serverToCanonical(
|
||||
glm::vec3(data.destX, data.destY, data.destZ));
|
||||
|
||||
// Calculate facing angle
|
||||
float orientation = entity->getOrientation();
|
||||
if (data.moveType == 4) {
|
||||
// FacingAngle - server specifies exact angle
|
||||
orientation = data.facingAngle;
|
||||
} else if (data.moveType == 3) {
|
||||
// FacingTarget - face toward the target entity
|
||||
auto target = entityManager.getEntity(data.facingTarget);
|
||||
if (target) {
|
||||
float dx = target->getX() - entity->getX();
|
||||
float dy = target->getY() - entity->getY();
|
||||
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
||||
orientation = std::atan2(dy, dx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal move - face toward destination
|
||||
float dx = destCanonical.x - entity->getX();
|
||||
float dy = destCanonical.y - entity->getY();
|
||||
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
||||
orientation = std::atan2(dy, dx);
|
||||
}
|
||||
}
|
||||
|
||||
// Set entity to destination for targeting/logic; renderer interpolates visually
|
||||
entity->setPosition(destCanonical.x, destCanonical.y, destCanonical.z, orientation);
|
||||
|
||||
// Notify renderer to smoothly move the creature
|
||||
if (creatureMoveCallback_) {
|
||||
creatureMoveCallback_(data.guid,
|
||||
destCanonical.x, destCanonical.y, destCanonical.z,
|
||||
data.duration);
|
||||
}
|
||||
} else if (data.moveType == 1) {
|
||||
// Stop at current position
|
||||
glm::vec3 posCanonical = core::coords::serverToCanonical(
|
||||
glm::vec3(data.x, data.y, data.z));
|
||||
entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z,
|
||||
entity->getOrientation());
|
||||
|
||||
if (creatureMoveCallback_) {
|
||||
creatureMoveCallback_(data.guid,
|
||||
posCanonical.x, posCanonical.y, posCanonical.z, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3556,6 +3702,20 @@ void GameHandler::acceptQuest() {
|
|||
auto packet = QuestgiverAcceptQuestPacket::build(
|
||||
currentQuestDetails.npcGuid, currentQuestDetails.questId);
|
||||
socket->send(packet);
|
||||
|
||||
// Add to quest log
|
||||
bool alreadyInLog = false;
|
||||
for (const auto& q : questLog_) {
|
||||
if (q.questId == currentQuestDetails.questId) { alreadyInLog = true; break; }
|
||||
}
|
||||
if (!alreadyInLog) {
|
||||
QuestLogEntry entry;
|
||||
entry.questId = currentQuestDetails.questId;
|
||||
entry.title = currentQuestDetails.title;
|
||||
entry.objectives = currentQuestDetails.objectives;
|
||||
questLog_.push_back(entry);
|
||||
}
|
||||
|
||||
questDetailsOpen = false;
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
}
|
||||
|
|
@ -3565,6 +3725,23 @@ void GameHandler::declineQuest() {
|
|||
currentQuestDetails = QuestDetailsData{};
|
||||
}
|
||||
|
||||
void GameHandler::abandonQuest(uint32_t questId) {
|
||||
// Find the quest's index in our local log
|
||||
for (size_t i = 0; i < questLog_.size(); i++) {
|
||||
if (questLog_[i].questId == questId) {
|
||||
// Tell server to remove it (slot index in server quest log)
|
||||
// We send the local index; server maps it via PLAYER_QUEST_LOG fields
|
||||
if (state == WorldState::IN_WORLD && socket) {
|
||||
network::Packet pkt(static_cast<uint16_t>(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
|
||||
pkt.writeUInt8(static_cast<uint8_t>(i));
|
||||
socket->send(pkt);
|
||||
}
|
||||
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(i));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GameHandler::closeGossip() {
|
||||
gossipWindowOpen = false;
|
||||
currentGossip = GossipMessageData{};
|
||||
|
|
@ -3593,6 +3770,28 @@ void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count
|
|||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::sellItemBySlot(int backpackIndex) {
|
||||
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
||||
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
||||
if (slot.empty()) return;
|
||||
|
||||
if (singlePlayerMode_) {
|
||||
auto it = itemInfoCache_.find(slot.item.itemId);
|
||||
if (it != itemInfoCache_.end() && it->second.sellPrice > 0) {
|
||||
addMoneyCopper(it->second.sellPrice);
|
||||
std::string msg = "You sold " + slot.item.name + ".";
|
||||
addSystemChatMessage(msg);
|
||||
}
|
||||
inventory.clearBackpackSlot(backpackIndex);
|
||||
notifyInventoryChanged();
|
||||
} else {
|
||||
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
|
||||
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
|
||||
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GameHandler::handleLootResponse(network::Packet& packet) {
|
||||
if (!LootResponseParser::parse(packet, currentLoot)) return;
|
||||
lootWindowOpen = true;
|
||||
|
|
|
|||
|
|
@ -575,11 +575,23 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u
|
|||
packet.writeBytes(reinterpret_cast<const uint8_t*>(&info.jumpXYSpeed), sizeof(float));
|
||||
}
|
||||
|
||||
static int mvLog = 10;
|
||||
// Detailed hex dump for debugging
|
||||
static int mvLog = 5;
|
||||
if (mvLog-- > 0) {
|
||||
LOG_INFO("Movement pkt: opcode=0x", std::hex, static_cast<uint16_t>(opcode), std::dec,
|
||||
" size=", packet.getSize(), " flags=0x", std::hex, info.flags, std::dec,
|
||||
" pos=(", info.x, ",", info.y, ",", info.z, ")");
|
||||
const auto& raw = packet.getData();
|
||||
std::string hex;
|
||||
for (size_t i = 0; i < raw.size(); i++) {
|
||||
char b[4]; snprintf(b, sizeof(b), "%02x ", raw[i]);
|
||||
hex += b;
|
||||
}
|
||||
LOG_INFO("MOVEPKT opcode=0x", std::hex, static_cast<uint16_t>(opcode), std::dec,
|
||||
" guid=0x", std::hex, playerGuid, std::dec,
|
||||
" payload=", raw.size(), " bytes",
|
||||
" flags=0x", std::hex, info.flags, std::dec,
|
||||
" flags2=0x", std::hex, info.flags2, std::dec,
|
||||
" pos=(", info.x, ",", info.y, ",", info.z, ",", info.orientation, ")",
|
||||
" fallTime=", info.fallTime);
|
||||
LOG_INFO("MOVEPKT hex: ", hex);
|
||||
}
|
||||
|
||||
return packet;
|
||||
|
|
@ -1288,7 +1300,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
|
|||
packet.readUInt32(); // Flags
|
||||
packet.readUInt32(); // Flags2
|
||||
packet.readUInt32(); // BuyPrice
|
||||
packet.readUInt32(); // SellPrice
|
||||
data.sellPrice = packet.readUInt32(); // SellPrice
|
||||
|
||||
data.inventoryType = packet.readUInt32();
|
||||
|
||||
|
|
@ -1339,6 +1351,118 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
|
|||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Creature Movement
|
||||
// ============================================================
|
||||
|
||||
bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) {
|
||||
// PackedGuid
|
||||
data.guid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (data.guid == 0) return false;
|
||||
|
||||
// uint8 unk (toggle for MOVEMENTFLAG2_UNK7)
|
||||
if (packet.getReadPos() >= packet.getSize()) return false;
|
||||
packet.readUInt8();
|
||||
|
||||
// Current position (server coords: float x, y, z)
|
||||
if (packet.getReadPos() + 12 > packet.getSize()) return false;
|
||||
data.x = packet.readFloat();
|
||||
data.y = packet.readFloat();
|
||||
data.z = packet.readFloat();
|
||||
|
||||
// uint32 splineId
|
||||
if (packet.getReadPos() + 4 > packet.getSize()) return false;
|
||||
packet.readUInt32();
|
||||
|
||||
// uint8 moveType
|
||||
if (packet.getReadPos() >= packet.getSize()) return false;
|
||||
data.moveType = packet.readUInt8();
|
||||
|
||||
if (data.moveType == 1) {
|
||||
// Stop - no more required data
|
||||
data.destX = data.x;
|
||||
data.destY = data.y;
|
||||
data.destZ = data.z;
|
||||
data.hasDest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read facing data based on move type
|
||||
if (data.moveType == 2) {
|
||||
// FacingSpot: float x, y, z
|
||||
if (packet.getReadPos() + 12 > packet.getSize()) return false;
|
||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
||||
} else if (data.moveType == 3) {
|
||||
// FacingTarget: uint64 guid
|
||||
if (packet.getReadPos() + 8 > packet.getSize()) return false;
|
||||
data.facingTarget = packet.readUInt64();
|
||||
} else if (data.moveType == 4) {
|
||||
// FacingAngle: float angle
|
||||
if (packet.getReadPos() + 4 > packet.getSize()) return false;
|
||||
data.facingAngle = packet.readFloat();
|
||||
}
|
||||
|
||||
// uint32 splineFlags
|
||||
if (packet.getReadPos() + 4 > packet.getSize()) return false;
|
||||
data.splineFlags = packet.readUInt32();
|
||||
|
||||
// Check for animation flag (0x00000100)
|
||||
if (data.splineFlags & 0x00000100) {
|
||||
if (packet.getReadPos() + 8 > packet.getSize()) return false;
|
||||
packet.readUInt32(); // animId
|
||||
packet.readUInt32(); // effectStartTime
|
||||
}
|
||||
|
||||
// uint32 duration
|
||||
if (packet.getReadPos() + 4 > packet.getSize()) return false;
|
||||
data.duration = packet.readUInt32();
|
||||
|
||||
// Check for parabolic flag (0x00000200)
|
||||
if (data.splineFlags & 0x00000200) {
|
||||
if (packet.getReadPos() + 8 > packet.getSize()) return false;
|
||||
packet.readFloat(); // vertAccel
|
||||
packet.readUInt32(); // effectStartTime
|
||||
}
|
||||
|
||||
// uint32 pointCount
|
||||
if (packet.getReadPos() + 4 > packet.getSize()) return false;
|
||||
uint32_t pointCount = packet.readUInt32();
|
||||
|
||||
if (pointCount == 0) return true;
|
||||
|
||||
// Read destination point(s)
|
||||
// If UncompressedPath flag (0x00040000): all points are full float x,y,z
|
||||
// Otherwise: first is packed destination, rest are packed deltas
|
||||
bool uncompressed = (data.splineFlags & 0x00040000) != 0;
|
||||
|
||||
if (uncompressed) {
|
||||
// Read last point as destination
|
||||
// Skip to last point: each point is 12 bytes
|
||||
for (uint32_t i = 0; i < pointCount - 1; i++) {
|
||||
if (packet.getReadPos() + 12 > packet.getSize()) return true;
|
||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
||||
}
|
||||
if (packet.getReadPos() + 12 > packet.getSize()) return true;
|
||||
data.destX = packet.readFloat();
|
||||
data.destY = packet.readFloat();
|
||||
data.destZ = packet.readFloat();
|
||||
data.hasDest = true;
|
||||
} else {
|
||||
// Compressed: first 3 floats are the destination (final point)
|
||||
if (packet.getReadPos() + 12 > packet.getSize()) return true;
|
||||
data.destX = packet.readFloat();
|
||||
data.destY = packet.readFloat();
|
||||
data.destZ = packet.readFloat();
|
||||
data.hasDest = true;
|
||||
}
|
||||
|
||||
LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec,
|
||||
" type=", (int)data.moveType, " dur=", data.duration, "ms",
|
||||
" dest=(", data.destX, ",", data.destY, ",", data.destZ, ")");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 2: Combat Core
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -144,6 +144,22 @@ void WorldSocket::send(const Packet& packet) {
|
|||
LOG_DEBUG("Sending world packet: opcode=0x", std::hex, opcode, std::dec,
|
||||
" payload=", payloadLen, " bytes (", sendData.size(), " total)");
|
||||
|
||||
// Debug: dump first few movement packets
|
||||
{
|
||||
static int moveDump = 3;
|
||||
bool isMove = (opcode >= 0xB5 && opcode <= 0xBE) || opcode == 0xC9 || opcode == 0xDA || opcode == 0xEE;
|
||||
if (isMove && moveDump-- > 0) {
|
||||
std::string hex = "MOVE PKT dump opcode=0x";
|
||||
char buf[8]; snprintf(buf, sizeof(buf), "%03x", opcode); hex += buf;
|
||||
hex += " payload=" + std::to_string(payloadLen) + " bytes: ";
|
||||
for (size_t i = 6; i < sendData.size() && i < 6 + 48; i++) {
|
||||
char b[4]; snprintf(b, sizeof(b), "%02x ", sendData[i]);
|
||||
hex += b;
|
||||
}
|
||||
LOG_INFO(hex);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: dump packet bytes for AUTH_SESSION
|
||||
if (opcode == 0x1ED) {
|
||||
std::string hexDump = "AUTH_SESSION raw bytes: ";
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ void CameraController::startIntroPan(float durationSec, float orbitDegrees) {
|
|||
if (!camera) return;
|
||||
introActive = true;
|
||||
introTimer = 0.0f;
|
||||
idleTimer_ = 0.0f;
|
||||
introDuration = std::max(0.5f, durationSec);
|
||||
introStartYaw = facingYaw + orbitDegrees;
|
||||
introEndYaw = facingYaw;
|
||||
|
|
@ -96,9 +97,24 @@ void CameraController::update(float deltaTime) {
|
|||
bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL));
|
||||
bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_SPACE);
|
||||
|
||||
// Idle camera: any input resets the timer; timeout triggers a slow orbit pan
|
||||
bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump;
|
||||
if (anyInput) {
|
||||
idleTimer_ = 0.0f;
|
||||
} else if (!introActive) {
|
||||
idleTimer_ += deltaTime;
|
||||
if (idleTimer_ >= IDLE_TIMEOUT) {
|
||||
idleTimer_ = 0.0f;
|
||||
startIntroPan(6.0f, 360.0f); // Slow full orbit
|
||||
idleOrbit_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (introActive) {
|
||||
if (leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump) {
|
||||
if (anyInput) {
|
||||
introActive = false;
|
||||
idleOrbit_ = false;
|
||||
idleTimer_ = 0.0f;
|
||||
} else {
|
||||
introTimer += deltaTime;
|
||||
float t = (introDuration > 0.0f) ? std::min(introTimer / introDuration, 1.0f) : 1.0f;
|
||||
|
|
@ -109,7 +125,13 @@ void CameraController::update(float deltaTime) {
|
|||
camera->setRotation(yaw, pitch);
|
||||
facingYaw = yaw;
|
||||
if (t >= 1.0f) {
|
||||
introActive = false;
|
||||
if (idleOrbit_) {
|
||||
// Loop: restart the slow orbit continuously
|
||||
startIntroPan(6.0f, 360.0f);
|
||||
idleOrbit_ = true;
|
||||
} else {
|
||||
introActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress player movement/input during intro.
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ bool CharacterRenderer::initialize() {
|
|||
uniform mat4 uLightSpaceMatrix;
|
||||
uniform int uShadowEnabled;
|
||||
uniform float uShadowStrength;
|
||||
uniform float uOpacity;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
|
|
@ -154,8 +155,8 @@ bool CharacterRenderer::initialize() {
|
|||
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
|
||||
result = mix(uFogColor, result, fogFactor);
|
||||
|
||||
// Force alpha=1 for opaque character rendering (baked NPC textures may have alpha=0)
|
||||
FragColor = vec4(result, 1.0);
|
||||
// Apply opacity (for fade-in effects)
|
||||
FragColor = vec4(result, uOpacity);
|
||||
}
|
||||
)";
|
||||
|
||||
|
|
@ -906,6 +907,35 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId,
|
|||
}
|
||||
|
||||
void CharacterRenderer::update(float deltaTime) {
|
||||
// Update fade-in opacity
|
||||
for (auto& [id, inst] : instances) {
|
||||
if (inst.fadeInDuration > 0.0f && inst.opacity < 1.0f) {
|
||||
inst.fadeInTime += deltaTime;
|
||||
inst.opacity = std::min(1.0f, inst.fadeInTime / inst.fadeInDuration);
|
||||
if (inst.opacity >= 1.0f) {
|
||||
inst.fadeInDuration = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate creature movement
|
||||
for (auto& [id, inst] : instances) {
|
||||
if (inst.isMoving) {
|
||||
inst.moveElapsed += deltaTime;
|
||||
float t = inst.moveElapsed / inst.moveDuration;
|
||||
if (t >= 1.0f) {
|
||||
inst.position = inst.moveEnd;
|
||||
inst.isMoving = false;
|
||||
// Return to idle when movement completes
|
||||
if (inst.currentAnimationId == 4) {
|
||||
playAnimation(id, 0, true);
|
||||
}
|
||||
} else {
|
||||
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& pair : instances) {
|
||||
updateAnimation(pair.second, deltaTime);
|
||||
}
|
||||
|
|
@ -1123,6 +1153,8 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
characterShader->use();
|
||||
characterShader->setUniform("uView", view);
|
||||
|
|
@ -1155,11 +1187,15 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
|
||||
const auto& gpuModel = models[instance.modelId];
|
||||
|
||||
// Skip fully transparent instances
|
||||
if (instance.opacity <= 0.0f) continue;
|
||||
|
||||
// Set model matrix (use override for weapon instances)
|
||||
glm::mat4 modelMat = instance.hasOverrideModelMatrix
|
||||
? instance.overrideModelMatrix
|
||||
: getModelMatrix(instance);
|
||||
characterShader->setUniform("uModel", modelMat);
|
||||
characterShader->setUniform("uOpacity", instance.opacity);
|
||||
|
||||
// Set bone matrices (upload all at once for performance)
|
||||
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
|
||||
|
|
@ -1273,6 +1309,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
}
|
||||
|
||||
glBindVertexArray(0);
|
||||
glDisable(GL_BLEND);
|
||||
glEnable(GL_CULL_FACE); // Restore culling for other renderers
|
||||
}
|
||||
|
||||
|
|
@ -1379,6 +1416,55 @@ void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3
|
|||
}
|
||||
}
|
||||
|
||||
void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it == instances.end()) return;
|
||||
|
||||
auto& inst = it->second;
|
||||
if (durationSeconds <= 0.0f) {
|
||||
// Instant move (stop)
|
||||
inst.position = destination;
|
||||
inst.isMoving = false;
|
||||
// Return to idle animation if currently walking
|
||||
if (inst.currentAnimationId == 4) {
|
||||
playAnimation(instanceId, 0, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
inst.moveStart = inst.position;
|
||||
inst.moveEnd = destination;
|
||||
inst.moveDuration = durationSeconds;
|
||||
inst.moveElapsed = 0.0f;
|
||||
inst.isMoving = true;
|
||||
|
||||
// Face toward destination (yaw around Z axis since Z is up)
|
||||
glm::vec3 dir = destination - inst.position;
|
||||
if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) {
|
||||
float angle = std::atan2(dir.y, dir.x);
|
||||
inst.rotation.z = angle;
|
||||
}
|
||||
|
||||
// Play walk animation (ID 4) while moving
|
||||
if (inst.currentAnimationId == 0) {
|
||||
playAnimation(instanceId, 4, true);
|
||||
}
|
||||
}
|
||||
|
||||
const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const {
|
||||
auto it = models.find(modelId);
|
||||
if (it == models.end()) return nullptr;
|
||||
return &it->second.data;
|
||||
}
|
||||
|
||||
void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it == instances.end()) return;
|
||||
it->second.opacity = 0.0f;
|
||||
it->second.fadeInTime = 0.0f;
|
||||
it->second.fadeInDuration = durationSeconds;
|
||||
}
|
||||
|
||||
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it != instances.end()) {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@ uint32_t Renderer::resolveMeleeAnimId() {
|
|||
return 0.0f;
|
||||
};
|
||||
|
||||
const uint32_t attackCandidates[] = {16, 17, 18, 19, 20, 21};
|
||||
// Prefer weapon attacks (1H=17, 2H=18) over unarmed (16); 19-21 are other variants
|
||||
const uint32_t attackCandidates[] = {17, 18, 16, 19, 20, 21};
|
||||
for (uint32_t id : attackCandidates) {
|
||||
if (characterRenderer->hasAnimation(characterInstanceId, id)) {
|
||||
meleeAnimId = id;
|
||||
|
|
@ -1032,6 +1033,113 @@ void Renderer::update(float deltaTime) {
|
|||
lastUpdateMs = std::chrono::duration<double, std::milli>(updateEnd - updateStart).count();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Selection Circle
|
||||
// ============================================================
|
||||
|
||||
void Renderer::initSelectionCircle() {
|
||||
if (selCircleVAO) return;
|
||||
|
||||
// Minimal shader: position + uniform MVP + color
|
||||
const char* vsSrc = R"(
|
||||
#version 330 core
|
||||
layout(location = 0) in vec3 aPos;
|
||||
uniform mat4 uMVP;
|
||||
void main() {
|
||||
gl_Position = uMVP * vec4(aPos, 1.0);
|
||||
}
|
||||
)";
|
||||
const char* fsSrc = R"(
|
||||
#version 330 core
|
||||
uniform vec3 uColor;
|
||||
out vec4 FragColor;
|
||||
void main() {
|
||||
FragColor = vec4(uColor, 0.6);
|
||||
}
|
||||
)";
|
||||
|
||||
auto compile = [](GLenum type, const char* src) -> GLuint {
|
||||
GLuint s = glCreateShader(type);
|
||||
glShaderSource(s, 1, &src, nullptr);
|
||||
glCompileShader(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
GLuint vs = compile(GL_VERTEX_SHADER, vsSrc);
|
||||
GLuint fs = compile(GL_FRAGMENT_SHADER, fsSrc);
|
||||
selCircleShader = glCreateProgram();
|
||||
glAttachShader(selCircleShader, vs);
|
||||
glAttachShader(selCircleShader, fs);
|
||||
glLinkProgram(selCircleShader);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
|
||||
// Build ring vertices (two concentric circles forming a strip)
|
||||
constexpr int SEGMENTS = 48;
|
||||
constexpr float INNER = 0.85f;
|
||||
constexpr float OUTER = 1.0f;
|
||||
std::vector<float> verts;
|
||||
for (int i = 0; i <= SEGMENTS; i++) {
|
||||
float angle = 2.0f * 3.14159265f * static_cast<float>(i) / static_cast<float>(SEGMENTS);
|
||||
float c = std::cos(angle), s = std::sin(angle);
|
||||
// Outer vertex
|
||||
verts.push_back(c * OUTER);
|
||||
verts.push_back(s * OUTER);
|
||||
verts.push_back(0.0f);
|
||||
// Inner vertex
|
||||
verts.push_back(c * INNER);
|
||||
verts.push_back(s * INNER);
|
||||
verts.push_back(0.0f);
|
||||
}
|
||||
selCircleVertCount = static_cast<int>((SEGMENTS + 1) * 2);
|
||||
|
||||
glGenVertexArrays(1, &selCircleVAO);
|
||||
glGenBuffers(1, &selCircleVBO);
|
||||
glBindVertexArray(selCircleVAO);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, selCircleVBO);
|
||||
glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
|
||||
glEnableVertexAttribArray(0);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
void Renderer::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) {
|
||||
selCirclePos = pos;
|
||||
selCircleRadius = radius;
|
||||
selCircleColor = color;
|
||||
selCircleVisible = true;
|
||||
}
|
||||
|
||||
void Renderer::clearSelectionCircle() {
|
||||
selCircleVisible = false;
|
||||
}
|
||||
|
||||
void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) {
|
||||
if (!selCircleVisible) return;
|
||||
initSelectionCircle();
|
||||
|
||||
glm::mat4 model = glm::translate(glm::mat4(1.0f), selCirclePos);
|
||||
model = glm::scale(model, glm::vec3(selCircleRadius));
|
||||
|
||||
glm::mat4 mvp = projection * view * model;
|
||||
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glDisable(GL_CULL_FACE);
|
||||
glDepthMask(GL_FALSE);
|
||||
|
||||
glUseProgram(selCircleShader);
|
||||
glUniformMatrix4fv(glGetUniformLocation(selCircleShader, "uMVP"), 1, GL_FALSE, &mvp[0][0]);
|
||||
glUniform3fv(glGetUniformLocation(selCircleShader, "uColor"), 1, &selCircleColor[0]);
|
||||
|
||||
glBindVertexArray(selCircleVAO);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, selCircleVertCount);
|
||||
glBindVertexArray(0);
|
||||
|
||||
glDepthMask(GL_TRUE);
|
||||
glEnable(GL_CULL_FACE);
|
||||
}
|
||||
|
||||
void Renderer::renderWorld(game::World* world) {
|
||||
auto renderStart = std::chrono::steady_clock::now();
|
||||
lastTerrainRenderMs = 0.0;
|
||||
|
|
@ -1157,6 +1265,9 @@ void Renderer::renderWorld(game::World* world) {
|
|||
characterRenderer->render(*camera, view, projection);
|
||||
}
|
||||
|
||||
// Render selection circle under targeted creature
|
||||
renderSelectionCircle(view, projection);
|
||||
|
||||
// Render WMO buildings (after characters, before UI)
|
||||
if (wmoRenderer && camera) {
|
||||
auto wmoStart = std::chrono::steady_clock::now();
|
||||
|
|
|
|||
|
|
@ -95,12 +95,26 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
|||
// Teleporter panel (T key toggle handled in Application event loop)
|
||||
renderTeleporterPanel();
|
||||
|
||||
// Quest Log (L key toggle handled inside)
|
||||
questLogScreen.render(gameHandler);
|
||||
|
||||
// Spellbook (P key toggle handled inside)
|
||||
spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager());
|
||||
|
||||
// Inventory (B key toggle handled inside)
|
||||
// Set vendor mode before rendering inventory
|
||||
inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler);
|
||||
|
||||
// Auto-open bags when vendor window opens
|
||||
if (gameHandler.isVendorWindowOpen() && !inventoryScreen.isOpen()) {
|
||||
inventoryScreen.setOpen(true);
|
||||
}
|
||||
|
||||
// Bags (B key toggle handled inside)
|
||||
inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper());
|
||||
|
||||
// Character screen (C key toggle handled inside render())
|
||||
inventoryScreen.renderCharacterScreen(gameHandler.getInventory());
|
||||
|
||||
if (inventoryScreen.consumeInventoryDirty()) {
|
||||
gameHandler.notifyInventoryChanged();
|
||||
}
|
||||
|
|
@ -112,7 +126,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
|||
gameHandler.notifyEquipmentChanged();
|
||||
}
|
||||
|
||||
// Update renderer face-target position
|
||||
// Update renderer face-target position and selection circle
|
||||
auto* renderer = core::Application::getInstance().getRenderer();
|
||||
if (renderer) {
|
||||
static glm::vec3 targetGLPos;
|
||||
|
|
@ -121,11 +135,30 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
|||
if (target) {
|
||||
targetGLPos = core::coords::canonicalToRender(glm::vec3(target->getX(), target->getY(), target->getZ()));
|
||||
renderer->setTargetPosition(&targetGLPos);
|
||||
|
||||
// Selection circle color: red=hostile, green=friendly, gray=dead
|
||||
glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow
|
||||
float circleRadius = 1.5f;
|
||||
if (target->getType() == game::ObjectType::UNIT) {
|
||||
auto unit = std::static_pointer_cast<game::Unit>(target);
|
||||
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
|
||||
circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead)
|
||||
} else if (unit->isInteractable()) {
|
||||
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (friendly)
|
||||
} else {
|
||||
circleColor = glm::vec3(1.0f, 0.2f, 0.2f); // red (hostile)
|
||||
}
|
||||
} else if (target->getType() == game::ObjectType::PLAYER) {
|
||||
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (player)
|
||||
}
|
||||
renderer->setSelectionCircle(targetGLPos, circleRadius, circleColor);
|
||||
} else {
|
||||
renderer->setTargetPosition(nullptr);
|
||||
renderer->clearSelectionCircle();
|
||||
}
|
||||
} else {
|
||||
renderer->setTargetPosition(nullptr);
|
||||
renderer->clearSelectionCircle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -422,16 +455,29 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
|
|||
float closestT = 1e30f;
|
||||
uint64_t closestGuid = 0;
|
||||
|
||||
const uint64_t myGuid = gameHandler.getPlayerGuid();
|
||||
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
|
||||
auto t = entity->getType();
|
||||
if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue;
|
||||
if (guid == myGuid) continue; // Don't target self
|
||||
|
||||
// Scale hitbox based on entity type
|
||||
float hitRadius = 1.5f;
|
||||
float heightOffset = 1.5f;
|
||||
if (t == game::ObjectType::UNIT) {
|
||||
auto unit = std::static_pointer_cast<game::Unit>(entity);
|
||||
// Critters have very low max health (< 100)
|
||||
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
|
||||
hitRadius = 0.5f;
|
||||
heightOffset = 0.3f;
|
||||
}
|
||||
}
|
||||
|
||||
glm::vec3 entityGL = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
|
||||
// Add half-height offset so we target the body center, not feet
|
||||
entityGL.z += 3.0f;
|
||||
entityGL.z += heightOffset;
|
||||
|
||||
float hitT;
|
||||
if (raySphereIntersect(ray, entityGL, 3.0f, hitT)) {
|
||||
if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) {
|
||||
if (hitT < closestT) {
|
||||
closestT = hitT;
|
||||
closestGuid = guid;
|
||||
|
|
@ -505,11 +551,11 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
|
|||
|
||||
const auto& characters = gameHandler.getCharacters();
|
||||
if (!characters.empty()) {
|
||||
// Use the first (or most recently selected) character
|
||||
const auto& ch = characters[0];
|
||||
playerName = ch.name;
|
||||
playerLevel = ch.level;
|
||||
// Characters don't store HP; use level-scaled estimate
|
||||
// Use live server level if available, otherwise character struct
|
||||
playerLevel = gameHandler.getPlayerLevel();
|
||||
if (playerLevel == 0) playerLevel = ch.level;
|
||||
playerMaxHp = 20 + playerLevel * 10;
|
||||
playerHp = playerMaxHp;
|
||||
}
|
||||
|
|
@ -589,26 +635,30 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
|
|||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
|
||||
|
||||
// Determine hostility color for border and name
|
||||
ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f);
|
||||
if (target->getType() == game::ObjectType::PLAYER) {
|
||||
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
|
||||
} else if (target->getType() == game::ObjectType::UNIT) {
|
||||
auto u = std::static_pointer_cast<game::Unit>(target);
|
||||
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
|
||||
hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
|
||||
} else if (u->isInteractable()) {
|
||||
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
|
||||
} else {
|
||||
hostileColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f));
|
||||
|
||||
if (ImGui::Begin("##TargetFrame", nullptr, flags)) {
|
||||
// Entity name and type
|
||||
std::string name = getEntityName(target);
|
||||
|
||||
ImVec4 nameColor;
|
||||
switch (target->getType()) {
|
||||
case game::ObjectType::PLAYER:
|
||||
nameColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green
|
||||
break;
|
||||
case game::ObjectType::UNIT:
|
||||
nameColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // Yellow
|
||||
break;
|
||||
default:
|
||||
nameColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
||||
break;
|
||||
}
|
||||
ImVec4 nameColor = hostileColor;
|
||||
|
||||
ImGui::TextColored(nameColor, "%s", name.c_str());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "ui/inventory_screen.hpp"
|
||||
#include "game/game_handler.hpp"
|
||||
#include "core/input.hpp"
|
||||
#include <imgui.h>
|
||||
#include <SDL2/SDL.h>
|
||||
|
|
@ -32,13 +33,11 @@ game::EquipSlot InventoryScreen::getEquipSlotForType(uint8_t inventoryType, game
|
|||
case 9: return game::EquipSlot::WRISTS;
|
||||
case 10: return game::EquipSlot::HANDS;
|
||||
case 11: {
|
||||
// Ring: prefer empty slot, else RING1
|
||||
if (inv.getEquipSlot(game::EquipSlot::RING1).empty())
|
||||
return game::EquipSlot::RING1;
|
||||
return game::EquipSlot::RING2;
|
||||
}
|
||||
case 12: {
|
||||
// Trinket: prefer empty slot, else TRINKET1
|
||||
if (inv.getEquipSlot(game::EquipSlot::TRINKET1).empty())
|
||||
return game::EquipSlot::TRINKET1;
|
||||
return game::EquipSlot::TRINKET2;
|
||||
|
|
@ -99,7 +98,6 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) {
|
|||
game::ItemDef targetItem = target.item;
|
||||
inv.setBackpackSlot(index, heldItem);
|
||||
heldItem = targetItem;
|
||||
// Keep holding the swapped item - update source to this backpack slot
|
||||
heldSource = HeldSource::BACKPACK;
|
||||
heldBackpackIndex = index;
|
||||
}
|
||||
|
|
@ -112,19 +110,18 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo
|
|||
// Validate: check if the held item can go in this slot
|
||||
if (heldItem.inventoryType > 0) {
|
||||
game::EquipSlot validSlot = getEquipSlotForType(heldItem.inventoryType, inv);
|
||||
if (validSlot == game::EquipSlot::NUM_SLOTS) return; // Not equippable
|
||||
if (validSlot == game::EquipSlot::NUM_SLOTS) return;
|
||||
|
||||
// For rings/trinkets, allow either slot
|
||||
bool valid = (slot == validSlot);
|
||||
if (!valid) {
|
||||
if (heldItem.inventoryType == 11) // Ring
|
||||
if (heldItem.inventoryType == 11)
|
||||
valid = (slot == game::EquipSlot::RING1 || slot == game::EquipSlot::RING2);
|
||||
else if (heldItem.inventoryType == 12) // Trinket
|
||||
else if (heldItem.inventoryType == 12)
|
||||
valid = (slot == game::EquipSlot::TRINKET1 || slot == game::EquipSlot::TRINKET2);
|
||||
}
|
||||
if (!valid) return;
|
||||
} else {
|
||||
return; // No inventoryType means not equippable
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& target = inv.getEquipSlot(slot);
|
||||
|
|
@ -132,7 +129,6 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo
|
|||
inv.setEquipSlot(slot, heldItem);
|
||||
holdingItem = false;
|
||||
} else {
|
||||
// Swap
|
||||
game::ItemDef targetItem = target.item;
|
||||
inv.setEquipSlot(slot, heldItem);
|
||||
heldItem = targetItem;
|
||||
|
|
@ -163,13 +159,10 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo
|
|||
|
||||
void InventoryScreen::cancelPickup(game::Inventory& inv) {
|
||||
if (!holdingItem) return;
|
||||
// Return item to source
|
||||
if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) {
|
||||
// If source slot is still empty, put it back
|
||||
if (inv.getBackpackSlot(heldBackpackIndex).empty()) {
|
||||
inv.setBackpackSlot(heldBackpackIndex, heldItem);
|
||||
} else {
|
||||
// Source was swapped into; find free slot
|
||||
inv.addItem(heldItem);
|
||||
}
|
||||
} else if (heldSource == HeldSource::EQUIPMENT && heldEquipSlot != game::EquipSlot::NUM_SLOTS) {
|
||||
|
|
@ -180,7 +173,6 @@ void InventoryScreen::cancelPickup(game::Inventory& inv) {
|
|||
inv.addItem(heldItem);
|
||||
}
|
||||
} else {
|
||||
// Fallback: just add to inventory
|
||||
inv.addItem(heldItem);
|
||||
}
|
||||
holdingItem = false;
|
||||
|
|
@ -199,13 +191,11 @@ void InventoryScreen::renderHeldItem() {
|
|||
ImVec4 qColor = getQualityColor(heldItem.quality);
|
||||
ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor);
|
||||
|
||||
// Background
|
||||
drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size),
|
||||
IM_COL32(40, 35, 30, 200));
|
||||
drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size),
|
||||
borderCol, 0.0f, 0, 2.0f);
|
||||
|
||||
// Item abbreviation
|
||||
char abbr[4] = {};
|
||||
if (!heldItem.name.empty()) {
|
||||
abbr[0] = heldItem.name[0];
|
||||
|
|
@ -215,7 +205,6 @@ void InventoryScreen::renderHeldItem() {
|
|||
drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f),
|
||||
ImGui::ColorConvertFloat4ToU32(qColor), abbr);
|
||||
|
||||
// Stack count
|
||||
if (heldItem.stackCount > 1) {
|
||||
char countStr[16];
|
||||
snprintf(countStr, sizeof(countStr), "%u", heldItem.stackCount);
|
||||
|
|
@ -225,6 +214,10 @@ void InventoryScreen::renderHeldItem() {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bags window (B key) — bottom of screen, no equipment panel
|
||||
// ============================================================
|
||||
|
||||
void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
||||
// B key toggle (edge-triggered)
|
||||
bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
|
||||
|
|
@ -234,8 +227,14 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
|||
}
|
||||
bKeyWasDown = bDown;
|
||||
|
||||
// C key toggle for character screen (edge-triggered)
|
||||
bool cDown = !uiWantsKeyboard && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_C);
|
||||
if (cDown && !cKeyWasDown) {
|
||||
characterOpen = !characterOpen;
|
||||
}
|
||||
cKeyWasDown = cDown;
|
||||
|
||||
if (!open) {
|
||||
// Cancel held item if inventory closes
|
||||
if (holdingItem) cancelPickup(inventory);
|
||||
return;
|
||||
}
|
||||
|
|
@ -252,33 +251,42 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
|||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
float screenW = io.DisplaySize.x;
|
||||
float screenH = io.DisplaySize.y;
|
||||
|
||||
// Position inventory window on the right side of the screen
|
||||
ImGui::SetNextWindowPos(ImVec2(screenW - 520.0f, 80.0f), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(500.0f, 560.0f), ImGuiCond_FirstUseEver);
|
||||
// Calculate bag window size
|
||||
constexpr float slotSize = 40.0f;
|
||||
constexpr int columns = 4;
|
||||
int rows = (inventory.getBackpackSize() + columns - 1) / columns;
|
||||
float bagContentH = rows * (slotSize + 4.0f) + 40.0f; // slots + header + money
|
||||
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse;
|
||||
if (!ImGui::Begin("Inventory", &open, flags)) {
|
||||
// Check for extra bags and add space
|
||||
for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) {
|
||||
int bagSize = inventory.getBagSize(bag);
|
||||
if (bagSize <= 0) continue;
|
||||
int bagRows = (bagSize + columns - 1) / columns;
|
||||
bagContentH += bagRows * (slotSize + 4.0f) + 30.0f; // slots + header
|
||||
}
|
||||
|
||||
float windowW = columns * (slotSize + 4.0f) + 30.0f;
|
||||
float windowH = bagContentH + 50.0f; // padding
|
||||
|
||||
// Position at bottom-right of screen
|
||||
float posX = screenW - windowW - 10.0f;
|
||||
float posY = screenH - windowH - 60.0f; // above action bar area
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always);
|
||||
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove;
|
||||
if (!ImGui::Begin("Bags", &open, flags)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve space for money display at bottom
|
||||
float moneyHeight = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
|
||||
float panelHeight = ImGui::GetContentRegionAvail().y - moneyHeight;
|
||||
|
||||
// Two-column layout: Equipment (left) | Backpack (right)
|
||||
ImGui::BeginChild("EquipPanel", ImVec2(200.0f, panelHeight), true);
|
||||
renderEquipmentPanel(inventory);
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
ImGui::BeginChild("BackpackPanel", ImVec2(0.0f, panelHeight), true);
|
||||
renderBackpackPanel(inventory);
|
||||
ImGui::EndChild();
|
||||
|
||||
// Money display
|
||||
ImGui::Spacing();
|
||||
uint64_t gold = moneyCopper / 10000;
|
||||
uint64_t silver = (moneyCopper / 100) % 100;
|
||||
uint64_t copper = moneyCopper % 100;
|
||||
|
|
@ -288,10 +296,37 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
|||
static_cast<unsigned long long>(copper));
|
||||
ImGui::End();
|
||||
|
||||
// Draw held item at cursor (on top of everything)
|
||||
// Draw held item at cursor
|
||||
renderHeldItem();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Character screen (C key) — standalone equipment window
|
||||
// ============================================================
|
||||
|
||||
void InventoryScreen::renderCharacterScreen(game::Inventory& inventory) {
|
||||
if (!characterOpen) return;
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(20.0f, 80.0f), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(220.0f, 520.0f), ImGuiCond_FirstUseEver);
|
||||
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse;
|
||||
if (!ImGui::Begin("Character", &characterOpen, flags)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
renderEquipmentPanel(inventory);
|
||||
|
||||
ImGui::End();
|
||||
|
||||
// If both bags and character are open, allow drag-and-drop between them
|
||||
// (held item rendering is handled in render())
|
||||
if (open) {
|
||||
renderHeldItem();
|
||||
}
|
||||
}
|
||||
|
||||
void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Equipment");
|
||||
ImGui::Separator();
|
||||
|
|
@ -312,10 +347,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
|
|||
constexpr float slotSize = 36.0f;
|
||||
constexpr float spacing = 4.0f;
|
||||
|
||||
// Two columns of equipment
|
||||
int rows = 8;
|
||||
for (int r = 0; r < rows; r++) {
|
||||
// Left slot
|
||||
{
|
||||
const auto& slot = inventory.getEquipSlot(leftSlots[r]);
|
||||
const char* label = game::getEquipSlotName(leftSlots[r]);
|
||||
|
|
@ -329,7 +362,6 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
|
|||
|
||||
ImGui::SameLine(slotSize + spacing + 60.0f);
|
||||
|
||||
// Right slot
|
||||
{
|
||||
const auto& slot = inventory.getEquipSlot(rightSlots[r]);
|
||||
const char* label = game::getEquipSlotName(rightSlots[r]);
|
||||
|
|
@ -420,7 +452,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
bool validDrop = false;
|
||||
if (holdingItem) {
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
validDrop = true; // Can always drop in backpack
|
||||
validDrop = true;
|
||||
} else if (kind == SlotKind::EQUIPMENT && heldItem.inventoryType > 0) {
|
||||
game::EquipSlot validSlot = getEquipSlotForType(heldItem.inventoryType, inventory);
|
||||
validDrop = (equipSlot == validSlot);
|
||||
|
|
@ -432,11 +464,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
}
|
||||
|
||||
if (isEmpty) {
|
||||
// Empty slot: dark grey background
|
||||
ImU32 bgCol = IM_COL32(30, 30, 30, 200);
|
||||
ImU32 borderCol = IM_COL32(60, 60, 60, 200);
|
||||
|
||||
// Highlight valid drop targets
|
||||
if (validDrop) {
|
||||
bgCol = IM_COL32(20, 50, 20, 200);
|
||||
borderCol = IM_COL32(0, 180, 0, 200);
|
||||
|
|
@ -445,7 +475,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
drawList->AddRectFilled(pos, ImVec2(pos.x + size, pos.y + size), bgCol);
|
||||
drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size), borderCol);
|
||||
|
||||
// Slot label for equipment slots
|
||||
if (label) {
|
||||
char abbr[4] = {};
|
||||
abbr[0] = label[0];
|
||||
|
|
@ -457,7 +486,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
|
||||
ImGui::InvisibleButton("slot", ImVec2(size, size));
|
||||
|
||||
// Click interactions
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && holdingItem && validDrop) {
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
placeInBackpack(inventory, backpackIndex);
|
||||
|
|
@ -466,7 +494,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
}
|
||||
}
|
||||
|
||||
// Tooltip for empty equip slots
|
||||
if (label && ImGui::IsItemHovered()) {
|
||||
ImGui::BeginTooltip();
|
||||
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "%s", label);
|
||||
|
|
@ -478,7 +505,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
ImVec4 qColor = getQualityColor(item.quality);
|
||||
ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor);
|
||||
|
||||
// Highlight valid drop targets with green tint
|
||||
ImU32 bgCol = IM_COL32(40, 35, 30, 220);
|
||||
if (holdingItem && validDrop) {
|
||||
bgCol = IM_COL32(30, 55, 30, 220);
|
||||
|
|
@ -489,7 +515,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
drawList->AddRect(pos, ImVec2(pos.x + size, pos.y + size),
|
||||
borderCol, 0.0f, 0, 2.0f);
|
||||
|
||||
// Item abbreviation (first 2 letters)
|
||||
char abbr[4] = {};
|
||||
if (!item.name.empty()) {
|
||||
abbr[0] = item.name[0];
|
||||
|
|
@ -499,7 +524,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
drawList->AddText(ImVec2(pos.x + (size - textW) * 0.5f, pos.y + 2.0f),
|
||||
ImGui::ColorConvertFloat4ToU32(qColor), abbr);
|
||||
|
||||
// Stack count (bottom-right)
|
||||
if (item.stackCount > 1) {
|
||||
char countStr[16];
|
||||
snprintf(countStr, sizeof(countStr), "%u", item.stackCount);
|
||||
|
|
@ -513,14 +537,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
// Left-click: pickup or place/swap
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
|
||||
if (!holdingItem) {
|
||||
// Pick up this item
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
pickupFromBackpack(inventory, backpackIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT) {
|
||||
pickupFromEquipment(inventory, equipSlot);
|
||||
}
|
||||
} else {
|
||||
// Holding an item - place or swap
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
placeInBackpack(inventory, backpackIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT && validDrop) {
|
||||
|
|
@ -529,9 +551,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
}
|
||||
}
|
||||
|
||||
// Right-click: auto-equip from backpack, or unequip from equipment
|
||||
// Right-click: vendor sell (if vendor mode) or auto-equip/unequip
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Right) && !holdingItem) {
|
||||
if (kind == SlotKind::EQUIPMENT) {
|
||||
if (vendorMode_ && gameHandler_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
// Sell to vendor
|
||||
gameHandler_->sellItemBySlot(backpackIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT) {
|
||||
// Unequip: move to free backpack slot
|
||||
int freeSlot = inventory.findFreeBackpackSlot();
|
||||
if (freeSlot >= 0) {
|
||||
|
|
@ -541,8 +566,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
inventoryDirty = true;
|
||||
}
|
||||
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0 && item.inventoryType > 0) {
|
||||
// Auto-equip: find the right slot
|
||||
// Capture type before swap (item ref may become stale)
|
||||
// Auto-equip
|
||||
uint8_t equippingType = item.inventoryType;
|
||||
game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory);
|
||||
if (targetSlot != game::EquipSlot::NUM_SLOTS) {
|
||||
|
|
@ -551,12 +575,10 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
inventory.setEquipSlot(targetSlot, item);
|
||||
inventory.clearBackpackSlot(backpackIndex);
|
||||
} else {
|
||||
// Swap with equipped item
|
||||
game::ItemDef equippedItem = eqSlot.item;
|
||||
inventory.setEquipSlot(targetSlot, item);
|
||||
inventory.setBackpackSlot(backpackIndex, equippedItem);
|
||||
}
|
||||
// Two-handed weapon in main hand clears the off-hand
|
||||
if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) {
|
||||
const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND);
|
||||
if (!offHand.empty()) {
|
||||
|
|
@ -564,7 +586,6 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
inventory.clearEquipSlot(game::EquipSlot::OFF_HAND);
|
||||
}
|
||||
}
|
||||
// Equipping off-hand unequips a 2H weapon from main hand
|
||||
if (targetSlot == game::EquipSlot::OFF_HAND &&
|
||||
inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) {
|
||||
inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item);
|
||||
|
|
@ -645,6 +666,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item) {
|
|||
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Stack: %u/%u", item.stackCount, item.maxStack);
|
||||
}
|
||||
|
||||
// Sell price (when vendor is open)
|
||||
if (vendorMode_ && gameHandler_) {
|
||||
const auto* info = gameHandler_->getItemInfo(item.itemId);
|
||||
if (info && info->sellPrice > 0) {
|
||||
uint32_t g = info->sellPrice / 10000;
|
||||
uint32_t s = (info->sellPrice / 100) % 100;
|
||||
uint32_t c = info->sellPrice % 100;
|
||||
ImGui::Separator();
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndTooltip();
|
||||
}
|
||||
|
||||
|
|
|
|||
90
src/ui/quest_log_screen.cpp
Normal file
90
src/ui/quest_log_screen.cpp
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#include "ui/quest_log_screen.hpp"
|
||||
#include "core/application.hpp"
|
||||
#include "core/input.hpp"
|
||||
#include <imgui.h>
|
||||
|
||||
namespace wowee { namespace ui {
|
||||
|
||||
void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
||||
// L key toggle (edge-triggered)
|
||||
bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
|
||||
bool lDown = !uiWantsKeyboard && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L);
|
||||
if (lDown && !lKeyWasDown) {
|
||||
open = !open;
|
||||
}
|
||||
lKeyWasDown = lDown;
|
||||
|
||||
if (!open) return;
|
||||
|
||||
auto* window = core::Application::getInstance().getWindow();
|
||||
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
||||
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
|
||||
|
||||
float logW = 380.0f;
|
||||
float logH = std::min(450.0f, screenH - 120.0f);
|
||||
float logX = (screenW - logW) * 0.5f;
|
||||
float logY = 80.0f;
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(logX, logY), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(logW, logH), ImGuiCond_FirstUseEver);
|
||||
|
||||
bool stillOpen = true;
|
||||
if (ImGui::Begin("Quest Log", &stillOpen)) {
|
||||
const auto& quests = gameHandler.getQuestLog();
|
||||
|
||||
if (quests.empty()) {
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active quests.");
|
||||
} else {
|
||||
// Left panel: quest list
|
||||
ImGui::BeginChild("QuestList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4), true);
|
||||
for (size_t i = 0; i < quests.size(); i++) {
|
||||
const auto& q = quests[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
||||
ImVec4 color = q.complete
|
||||
? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) // Green for complete
|
||||
: ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // Gold for active
|
||||
|
||||
bool selected = (selectedIndex == static_cast<int>(i));
|
||||
if (ImGui::Selectable("##quest", selected, 0, ImVec2(0, 20))) {
|
||||
selectedIndex = static_cast<int>(i);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(color, "%s%s",
|
||||
q.title.c_str(),
|
||||
q.complete ? " (Complete)" : "");
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// Details panel for selected quest
|
||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(quests.size())) {
|
||||
const auto& sel = quests[static_cast<size_t>(selectedIndex)];
|
||||
|
||||
if (!sel.objectives.empty()) {
|
||||
ImGui::Separator();
|
||||
ImGui::TextWrapped("%s", sel.objectives.c_str());
|
||||
}
|
||||
|
||||
// Abandon button
|
||||
if (!sel.complete) {
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("Abandon Quest")) {
|
||||
gameHandler.abandonQuest(sel.questId);
|
||||
if (selectedIndex >= static_cast<int>(quests.size())) {
|
||||
selectedIndex = static_cast<int>(quests.size()) - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
|
||||
if (!stillOpen) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
}} // namespace wowee::ui
|
||||
Loading…
Add table
Add a link
Reference in a new issue