mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Fix character appearance, previews, mount seat, and online unequip
This commit is contained in:
parent
4a023e773b
commit
275914b4db
19 changed files with 743 additions and 113 deletions
|
|
@ -117,6 +117,9 @@ private:
|
|||
game::Race playerRace_ = game::Race::HUMAN;
|
||||
game::Gender playerGender_ = game::Gender::MALE;
|
||||
game::Class playerClass_ = game::Class::WARRIOR;
|
||||
uint64_t spawnedPlayerGuid_ = 0;
|
||||
uint32_t spawnedAppearanceBytes_ = 0;
|
||||
uint8_t spawnedFacialFeatures_ = 0;
|
||||
|
||||
// Weapon model ID counter (starting high to avoid collision with character model IDs)
|
||||
uint32_t nextWeaponModelId_ = 1000;
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ public:
|
|||
Inventory& getInventory() { return inventory; }
|
||||
const Inventory& getInventory() const { return inventory; }
|
||||
bool consumeOnlineEquipmentDirty() { bool d = onlineEquipDirty_; onlineEquipDirty_ = false; return d; }
|
||||
void unequipToBackpack(EquipSlot equipSlot);
|
||||
|
||||
// Targeting
|
||||
void setTarget(uint64_t guid);
|
||||
|
|
|
|||
|
|
@ -261,6 +261,8 @@ enum class Opcode : uint16_t {
|
|||
SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058,
|
||||
CMSG_USE_ITEM = 0x00AB,
|
||||
CMSG_AUTOEQUIP_ITEM = 0x10A,
|
||||
CMSG_SWAP_ITEM = 0x10C,
|
||||
CMSG_SWAP_INV_ITEM = 0x10D,
|
||||
SMSG_INVENTORY_CHANGE_FAILURE = 0x112,
|
||||
CMSG_INSPECT = 0x114,
|
||||
SMSG_INSPECT_RESULTS = 0x115,
|
||||
|
|
|
|||
|
|
@ -1495,6 +1495,21 @@ public:
|
|||
static network::Packet build(uint8_t srcBag, uint8_t srcSlot);
|
||||
};
|
||||
|
||||
/** CMSG_SWAP_ITEM packet builder */
|
||||
class SwapItemPacket {
|
||||
public:
|
||||
// Order matches AzerothCore handler: destBag, destSlot, srcBag, srcSlot.
|
||||
static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot);
|
||||
};
|
||||
|
||||
/** CMSG_SWAP_INV_ITEM packet builder */
|
||||
class SwapInvItemPacket {
|
||||
public:
|
||||
// WoW inventory: slots are in the "inventory" range (equipment 0-18, bags 19-22, backpack 23-38).
|
||||
// This swaps two inventory slots directly.
|
||||
static network::Packet build(uint8_t srcSlot, uint8_t dstSlot);
|
||||
};
|
||||
|
||||
/** CMSG_LOOT_MONEY packet builder (empty body) */
|
||||
class LootMoneyPacket {
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
#include <GL/glew.h>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace wowee {
|
||||
namespace pipeline { class AssetManager; }
|
||||
|
|
@ -25,6 +27,9 @@ public:
|
|||
uint8_t hairStyle, uint8_t hairColor,
|
||||
uint8_t facialHair, bool useFemaleModel = false);
|
||||
|
||||
// Apply equipment overlays/geosets using SMSG_CHAR_ENUM equipment data (ItemDisplayInfo.dbc).
|
||||
bool applyEquipment(const std::vector<game::EquipmentItem>& equipment);
|
||||
|
||||
void update(float deltaTime);
|
||||
void render();
|
||||
void rotate(float yawDelta);
|
||||
|
|
@ -56,6 +61,16 @@ private:
|
|||
uint32_t instanceId_ = 0;
|
||||
bool modelLoaded_ = false;
|
||||
float modelYaw_ = 180.0f;
|
||||
|
||||
// Cached info from loadCharacter() for later recompositing.
|
||||
game::Race race_ = game::Race::HUMAN;
|
||||
game::Gender gender_ = game::Gender::MALE;
|
||||
bool useFemaleModel_ = false;
|
||||
uint8_t hairStyle_ = 0;
|
||||
uint8_t facialHair_ = 0;
|
||||
std::string bodySkinPath_;
|
||||
std::vector<std::string> baseLayers_; // face + underwear, etc.
|
||||
uint32_t skinTextureSlotIndex_ = 0;
|
||||
};
|
||||
|
||||
} // namespace rendering
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include <functional>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
|
||||
namespace wowee {
|
||||
namespace game { class GameHandler; }
|
||||
|
|
@ -39,6 +40,10 @@ private:
|
|||
std::string statusMessage;
|
||||
bool statusIsError = false;
|
||||
|
||||
// For many races/styles, CharSections hair color IDs are not guaranteed to be contiguous.
|
||||
// We expose an index (hairColor) in the UI and map it to the actual DBC hairColorId here.
|
||||
std::vector<uint8_t> hairColorIds_;
|
||||
|
||||
std::vector<game::Class> availableClasses;
|
||||
void updateAvailableClasses();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@
|
|||
#include <imgui.h>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace wowee { namespace ui {
|
||||
namespace wowee {
|
||||
namespace pipeline { class AssetManager; }
|
||||
namespace rendering { class CharacterPreview; }
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* Character selection screen UI
|
||||
|
|
@ -22,6 +26,11 @@ public:
|
|||
*/
|
||||
void render(game::GameHandler& gameHandler);
|
||||
|
||||
void setAssetManager(pipeline::AssetManager* am) {
|
||||
assetManager_ = am;
|
||||
previewInitialized_ = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for character selection
|
||||
* @param callback Function to call when character is selected (receives character GUID)
|
||||
|
|
@ -83,6 +92,17 @@ private:
|
|||
static std::string getConfigDir();
|
||||
void saveLastCharacter(uint64_t guid);
|
||||
uint64_t loadLastCharacter();
|
||||
|
||||
// Preview (3D character portrait)
|
||||
pipeline::AssetManager* assetManager_ = nullptr;
|
||||
std::unique_ptr<rendering::CharacterPreview> preview_;
|
||||
bool previewInitialized_ = false;
|
||||
uint64_t previewGuid_ = 0;
|
||||
uint32_t previewAppearanceBytes_ = 0;
|
||||
uint8_t previewFacialFeatures_ = 0;
|
||||
bool previewUseFemaleModel_ = false;
|
||||
uint64_t previewEquipHash_ = 0;
|
||||
};
|
||||
|
||||
}} // namespace wowee::ui
|
||||
} // namespace ui
|
||||
} // namespace wowee
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ bool Application::initialize() {
|
|||
// Eagerly load creature display DBC lookups so first spawn doesn't stall
|
||||
buildCreatureDisplayLookups();
|
||||
|
||||
// Ensure the main in-world CharacterRenderer can load textures immediately.
|
||||
// Previously this was only wired during terrain initialization, which meant early spawns
|
||||
// (before terrain load) would render with white fallback textures (notably hair).
|
||||
if (renderer && renderer->getCharacterRenderer()) {
|
||||
renderer->getCharacterRenderer()->setAssetManager(assetManager.get());
|
||||
}
|
||||
|
||||
// Load transport paths from TransportAnimation.dbc
|
||||
if (gameHandler && gameHandler->getTransportManager()) {
|
||||
gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get());
|
||||
|
|
@ -314,6 +321,26 @@ void Application::setState(AppState newState) {
|
|||
break;
|
||||
case AppState::CHARACTER_SELECTION:
|
||||
// Show character screen
|
||||
if (uiManager && assetManager) {
|
||||
uiManager->getCharacterScreen().setAssetManager(assetManager.get());
|
||||
}
|
||||
// Ensure no stale in-world player model leaks into the next login attempt.
|
||||
// If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync.
|
||||
npcsSpawned = false;
|
||||
playerCharacterSpawned = false;
|
||||
weaponsSheathed_ = false;
|
||||
wasAutoAttacking_ = false;
|
||||
spawnedPlayerGuid_ = 0;
|
||||
spawnedAppearanceBytes_ = 0;
|
||||
spawnedFacialFeatures_ = 0;
|
||||
if (renderer && renderer->getCharacterRenderer()) {
|
||||
uint32_t oldInst = renderer->getCharacterInstanceId();
|
||||
if (oldInst > 0) {
|
||||
renderer->setCharacterFollow(0);
|
||||
renderer->clearMount();
|
||||
renderer->getCharacterRenderer()->removeInstance(oldInst);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AppState::IN_GAME: {
|
||||
// Wire up movement opcodes from camera controller
|
||||
|
|
@ -1829,29 +1856,37 @@ void Application::spawnPlayerCharacter() {
|
|||
glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size
|
||||
|
||||
if (instanceId > 0) {
|
||||
// Set up third-person follow
|
||||
renderer->getCharacterPosition() = spawnPos;
|
||||
renderer->setCharacterFollow(instanceId);
|
||||
// Set up third-person follow
|
||||
renderer->getCharacterPosition() = spawnPos;
|
||||
renderer->setCharacterFollow(instanceId);
|
||||
|
||||
// Default geosets for naked human male
|
||||
// Use actual submesh IDs from the model (logged at load time)
|
||||
std::unordered_set<uint16_t> activeGeosets;
|
||||
// Body parts (group 0: IDs 0-99) - humanoid models may have many body submeshes
|
||||
for (uint16_t i = 0; i < 100; i++) {
|
||||
activeGeosets.insert(i);
|
||||
}
|
||||
// Equipment groups: "01" = bare skin, "02" = first equipped variant
|
||||
activeGeosets.insert(101); // Hair style 1
|
||||
activeGeosets.insert(201); // Facial hair: none
|
||||
activeGeosets.insert(301); // Gloves: bare hands
|
||||
activeGeosets.insert(401); // Boots: bare feet
|
||||
activeGeosets.insert(501); // Chest: bare
|
||||
activeGeosets.insert(701); // Ears: default
|
||||
activeGeosets.insert(1301); // Trousers: bare legs
|
||||
activeGeosets.insert(1501); // Back body (cloak=none)
|
||||
// 1703 = DK eye glow mesh — skip for normal characters
|
||||
// Normal eyes are part of the face texture on the body mesh
|
||||
charRenderer->setActiveGeosets(instanceId, activeGeosets);
|
||||
// Default geosets for the active character (match CharacterPreview logic).
|
||||
// Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world.
|
||||
std::unordered_set<uint16_t> activeGeosets;
|
||||
// Body parts (group 0)
|
||||
for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i);
|
||||
|
||||
uint8_t hairStyleId = 0;
|
||||
uint8_t facialId = 0;
|
||||
if (gameHandler) {
|
||||
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
|
||||
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
|
||||
facialId = ch->facialFeatures;
|
||||
}
|
||||
}
|
||||
// Hair style geoset: group 1 = 100 + variation + 1
|
||||
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
|
||||
// Facial hair geoset: group 2 = 200 + variation + 1
|
||||
activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1));
|
||||
activeGeosets.insert(301); // Gloves: bare hands
|
||||
activeGeosets.insert(401); // Boots: bare feet
|
||||
activeGeosets.insert(501); // Chest: bare
|
||||
activeGeosets.insert(701); // Ears: default
|
||||
activeGeosets.insert(1301); // Trousers: bare legs
|
||||
activeGeosets.insert(1501); // Back body (cloak=none)
|
||||
// 1703 = DK eye glow mesh — skip for normal characters
|
||||
// Normal eyes are part of the face texture on the body mesh
|
||||
charRenderer->setActiveGeosets(instanceId, activeGeosets);
|
||||
|
||||
// Play idle animation (Stand = animation ID 0)
|
||||
charRenderer->playAnimation(instanceId, 0, true);
|
||||
|
|
@ -1861,6 +1896,18 @@ void Application::spawnPlayerCharacter() {
|
|||
static_cast<int>(spawnPos.z), ")");
|
||||
playerCharacterSpawned = true;
|
||||
|
||||
// Track which character's appearance this instance represents so we can
|
||||
// respawn if the user logs into a different character without restarting.
|
||||
spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0;
|
||||
spawnedAppearanceBytes_ = 0;
|
||||
spawnedFacialFeatures_ = 0;
|
||||
if (gameHandler) {
|
||||
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
|
||||
spawnedAppearanceBytes_ = ch->appearanceBytes;
|
||||
spawnedFacialFeatures_ = ch->facialFeatures;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up camera controller for first-person player hiding
|
||||
if (renderer->getCameraController()) {
|
||||
renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId);
|
||||
|
|
@ -2235,11 +2282,40 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
if (gameHandler) {
|
||||
const game::Character* activeChar = gameHandler->getActiveCharacter();
|
||||
if (activeChar) {
|
||||
if (!playerCharacterSpawned) {
|
||||
const uint64_t activeGuid = gameHandler->getActiveCharacterGuid();
|
||||
const bool appearanceChanged =
|
||||
(activeGuid != spawnedPlayerGuid_) ||
|
||||
(activeChar->appearanceBytes != spawnedAppearanceBytes_) ||
|
||||
(activeChar->facialFeatures != spawnedFacialFeatures_) ||
|
||||
(activeChar->race != playerRace_) ||
|
||||
(activeChar->gender != playerGender_) ||
|
||||
(activeChar->characterClass != playerClass_);
|
||||
|
||||
if (!playerCharacterSpawned || appearanceChanged) {
|
||||
if (appearanceChanged) {
|
||||
LOG_INFO("Respawning player model for new/changed character: guid=0x",
|
||||
std::hex, activeGuid, std::dec);
|
||||
}
|
||||
// Remove old instance so we don't keep stale visuals.
|
||||
if (renderer && renderer->getCharacterRenderer()) {
|
||||
uint32_t oldInst = renderer->getCharacterInstanceId();
|
||||
if (oldInst > 0) {
|
||||
renderer->setCharacterFollow(0);
|
||||
renderer->clearMount();
|
||||
renderer->getCharacterRenderer()->removeInstance(oldInst);
|
||||
}
|
||||
}
|
||||
playerCharacterSpawned = false;
|
||||
spawnedPlayerGuid_ = 0;
|
||||
spawnedAppearanceBytes_ = 0;
|
||||
spawnedFacialFeatures_ = 0;
|
||||
|
||||
playerRace_ = activeChar->race;
|
||||
playerGender_ = activeChar->gender;
|
||||
playerClass_ = activeChar->characterClass;
|
||||
spawnSnapToGround = false;
|
||||
weaponsSheathed_ = false;
|
||||
loadEquippedWeapons(); // will no-op until instance exists
|
||||
spawnPlayerCharacter();
|
||||
}
|
||||
renderer->getCharacterPosition() = spawnRender;
|
||||
|
|
|
|||
|
|
@ -1485,6 +1485,10 @@ void GameHandler::requestCharacterList() {
|
|||
|
||||
LOG_INFO("Requesting character list from server...");
|
||||
|
||||
// Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM.
|
||||
// This matters after character create/delete where the old list can linger for a few frames.
|
||||
characters.clear();
|
||||
|
||||
// Build CMSG_CHAR_ENUM packet (no body, just opcode)
|
||||
auto packet = CharEnumPacket::build();
|
||||
|
||||
|
|
@ -1663,6 +1667,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Make the selected character authoritative in GameHandler.
|
||||
// This avoids relying on UI/Application ordering for appearance-dependent logic.
|
||||
activeCharacterGuid_ = characterGuid;
|
||||
|
||||
LOG_INFO("========================================");
|
||||
LOG_INFO(" ENTERING WORLD");
|
||||
LOG_INFO("========================================");
|
||||
|
|
@ -5980,6 +5988,28 @@ void GameHandler::autoEquipItemBySlot(int backpackIndex) {
|
|||
}
|
||||
}
|
||||
|
||||
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
|
||||
int freeSlot = inventory.findFreeBackpackSlot();
|
||||
if (freeSlot < 0) {
|
||||
addSystemChatMessage("Cannot unequip: no free backpack slots.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
|
||||
uint8_t srcBag = 0xFF;
|
||||
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
|
||||
uint8_t dstBag = 0xFF;
|
||||
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
|
||||
|
||||
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
|
||||
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
|
||||
|
||||
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
||||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::useItemBySlot(int backpackIndex) {
|
||||
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
||||
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
||||
|
|
|
|||
|
|
@ -2398,6 +2398,22 @@ network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) {
|
|||
return packet;
|
||||
}
|
||||
|
||||
network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) {
|
||||
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SWAP_ITEM));
|
||||
packet.writeUInt8(dstBag);
|
||||
packet.writeUInt8(dstSlot);
|
||||
packet.writeUInt8(srcBag);
|
||||
packet.writeUInt8(srcSlot);
|
||||
return packet;
|
||||
}
|
||||
|
||||
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
|
||||
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_SWAP_INV_ITEM));
|
||||
packet.writeUInt8(srcSlot);
|
||||
packet.writeUInt8(dstSlot);
|
||||
return packet;
|
||||
}
|
||||
|
||||
network::Packet LootMoneyPacket::build() {
|
||||
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LOOT_MONEY));
|
||||
return packet;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
|
||||
namespace {
|
||||
constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024;
|
||||
|
|
@ -130,6 +131,64 @@ void WorldSocket::send(const Packet& packet) {
|
|||
uint16_t opcode = packet.getOpcode();
|
||||
uint16_t payloadLen = static_cast<uint16_t>(data.size());
|
||||
|
||||
// Debug: parse and log character-create payload fields (helps diagnose appearance issues).
|
||||
if (opcode == 0x036) { // CMSG_CHAR_CREATE
|
||||
size_t pos = 0;
|
||||
std::string name;
|
||||
while (pos < data.size()) {
|
||||
uint8_t c = data[pos++];
|
||||
if (c == 0) break;
|
||||
name.push_back(static_cast<char>(c));
|
||||
}
|
||||
auto rd8 = [&](uint8_t& out) -> bool {
|
||||
if (pos >= data.size()) return false;
|
||||
out = data[pos++];
|
||||
return true;
|
||||
};
|
||||
uint8_t race = 0, cls = 0, gender = 0;
|
||||
uint8_t skin = 0, face = 0, hairStyle = 0, hairColor = 0, facial = 0, outfit = 0;
|
||||
bool ok =
|
||||
rd8(race) && rd8(cls) && rd8(gender) &&
|
||||
rd8(skin) && rd8(face) && rd8(hairStyle) && rd8(hairColor) && rd8(facial) && rd8(outfit);
|
||||
if (ok) {
|
||||
LOG_INFO("CMSG_CHAR_CREATE payload: name='", name,
|
||||
"' race=", (int)race, " class=", (int)cls, " gender=", (int)gender,
|
||||
" skin=", (int)skin, " face=", (int)face,
|
||||
" hairStyle=", (int)hairStyle, " hairColor=", (int)hairColor,
|
||||
" facial=", (int)facial, " outfit=", (int)outfit,
|
||||
" payloadLen=", payloadLen);
|
||||
// Persist to disk so we can compare TX vs DB even if the console scrolls away.
|
||||
std::ofstream f("charcreate_payload.log", std::ios::app);
|
||||
if (f.is_open()) {
|
||||
f << "name='" << name << "'"
|
||||
<< " race=" << (int)race
|
||||
<< " class=" << (int)cls
|
||||
<< " gender=" << (int)gender
|
||||
<< " skin=" << (int)skin
|
||||
<< " face=" << (int)face
|
||||
<< " hairStyle=" << (int)hairStyle
|
||||
<< " hairColor=" << (int)hairColor
|
||||
<< " facial=" << (int)facial
|
||||
<< " outfit=" << (int)outfit
|
||||
<< " payloadLen=" << payloadLen
|
||||
<< "\n";
|
||||
}
|
||||
} else {
|
||||
LOG_WARNING("CMSG_CHAR_CREATE payload too short to parse (name='", name,
|
||||
"' payloadLen=", payloadLen, " pos=", pos, ")");
|
||||
}
|
||||
}
|
||||
|
||||
if (opcode == 0x10C || opcode == 0x10D) { // CMSG_SWAP_ITEM / CMSG_SWAP_INV_ITEM
|
||||
std::string hex;
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%02x ", data[i]);
|
||||
hex += buf;
|
||||
}
|
||||
LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]");
|
||||
}
|
||||
|
||||
// WotLK 3.3.5 CMSG header (6 bytes total):
|
||||
// - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG)
|
||||
// - opcode (4 bytes, little-endian)
|
||||
|
|
|
|||
|
|
@ -150,11 +150,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
uint32_t targetRaceId = static_cast<uint32_t>(race);
|
||||
uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u;
|
||||
|
||||
std::string bodySkinPath;
|
||||
std::string faceLowerPath;
|
||||
std::string faceUpperPath;
|
||||
std::string hairScalpPath;
|
||||
std::vector<std::string> underwearPaths;
|
||||
bodySkinPath_.clear();
|
||||
baseLayers_.clear();
|
||||
|
||||
auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
|
||||
if (charSectionsDbc) {
|
||||
|
|
@ -177,7 +178,7 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
variationIndex == 0 && colorIndex == static_cast<uint32_t>(skin)) {
|
||||
std::string tex1 = charSectionsDbc->getString(r, 4);
|
||||
if (!tex1.empty()) {
|
||||
bodySkinPath = tex1;
|
||||
bodySkinPath_ = tex1;
|
||||
foundSkin = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -217,8 +218,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
|
||||
// Assign texture filenames on model before GPU upload
|
||||
for (auto& tex : model.textures) {
|
||||
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath.empty()) {
|
||||
tex.filename = bodySkinPath;
|
||||
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) {
|
||||
tex.filename = bodySkinPath_;
|
||||
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
|
||||
tex.filename = hairScalpPath;
|
||||
}
|
||||
|
|
@ -247,9 +248,9 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
}
|
||||
|
||||
// Composite body skin + face + underwear overlays
|
||||
if (!bodySkinPath.empty()) {
|
||||
if (!bodySkinPath_.empty()) {
|
||||
std::vector<std::string> layers;
|
||||
layers.push_back(bodySkinPath);
|
||||
layers.push_back(bodySkinPath_);
|
||||
// Face lower texture composited onto body at the face region
|
||||
if (!faceLowerPath.empty()) {
|
||||
layers.push_back(faceLowerPath);
|
||||
|
|
@ -261,6 +262,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
layers.push_back(up);
|
||||
}
|
||||
|
||||
// Cache for later equipment compositing.
|
||||
// Keep baseLayers_ without the base skin (compositeWithRegions takes basePath separately).
|
||||
if (!faceLowerPath.empty()) baseLayers_.push_back(faceLowerPath);
|
||||
if (!faceUpperPath.empty()) baseLayers_.push_back(faceUpperPath);
|
||||
for (const auto& up : underwearPaths) baseLayers_.push_back(up);
|
||||
|
||||
if (layers.size() > 1) {
|
||||
GLuint compositeTex = charRenderer_->compositeTextures(layers);
|
||||
if (compositeTex != 0) {
|
||||
|
|
@ -319,6 +326,22 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
// Play idle animation (Stand = animation ID 0)
|
||||
charRenderer_->playAnimation(instanceId_, 0, true);
|
||||
|
||||
// Cache core appearance for later equipment geosets.
|
||||
race_ = race;
|
||||
gender_ = gender;
|
||||
useFemaleModel_ = useFemaleModel;
|
||||
hairStyle_ = hairStyle;
|
||||
facialHair_ = facialHair;
|
||||
|
||||
// Cache the type-1 texture slot index so applyEquipment can update it.
|
||||
skinTextureSlotIndex_ = 0;
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
if (model.textures[ti].type == 1) {
|
||||
skinTextureSlotIndex_ = static_cast<uint32_t>(ti);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
modelLoaded_ = true;
|
||||
LOG_INFO("CharacterPreview: loaded ", m2Path,
|
||||
" skin=", (int)skin, " face=", (int)face,
|
||||
|
|
@ -327,6 +350,150 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
|||
return true;
|
||||
}
|
||||
|
||||
bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& equipment) {
|
||||
if (!modelLoaded_ || instanceId_ == 0 || !charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
|
||||
if (!displayInfoDbc || !displayInfoDbc->isLoaded()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
|
||||
for (const auto& it : equipment) {
|
||||
if (it.displayModel == 0) continue;
|
||||
for (uint8_t t : types) {
|
||||
if (it.inventoryType == t) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
auto findDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
|
||||
for (const auto& it : equipment) {
|
||||
if (it.displayModel == 0) continue;
|
||||
for (uint8_t t : types) {
|
||||
if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum)
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t {
|
||||
if (displayInfoId == 0) return 0;
|
||||
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
|
||||
if (recIdx < 0) return 0;
|
||||
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
|
||||
};
|
||||
|
||||
// --- Geosets ---
|
||||
std::unordered_set<uint16_t> geosets;
|
||||
for (uint16_t i = 0; i <= 18; i++) geosets.insert(i);
|
||||
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style
|
||||
geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair
|
||||
geosets.insert(701); // Ears
|
||||
|
||||
// Default naked geosets
|
||||
uint16_t geosetGloves = 301;
|
||||
uint16_t geosetBoots = 401;
|
||||
uint16_t geosetChest = 501;
|
||||
uint16_t geosetPants = 1301;
|
||||
|
||||
// Chest/Shirt/Robe
|
||||
{
|
||||
uint32_t did = findDisplayId({4, 5, 20});
|
||||
uint32_t gg = getGeosetGroup(did, 0);
|
||||
if (gg > 0) geosetChest = static_cast<uint16_t>(501 + gg);
|
||||
// Robe kilt legs
|
||||
uint32_t gg3 = getGeosetGroup(did, 2);
|
||||
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
|
||||
}
|
||||
// Legs
|
||||
{
|
||||
uint32_t did = findDisplayId({7});
|
||||
uint32_t gg = getGeosetGroup(did, 0);
|
||||
if (gg > 0) geosetPants = static_cast<uint16_t>(1301 + gg);
|
||||
}
|
||||
// Feet
|
||||
{
|
||||
uint32_t did = findDisplayId({8});
|
||||
uint32_t gg = getGeosetGroup(did, 0);
|
||||
if (gg > 0) geosetBoots = static_cast<uint16_t>(401 + gg);
|
||||
}
|
||||
// Hands
|
||||
{
|
||||
uint32_t did = findDisplayId({10});
|
||||
uint32_t gg = getGeosetGroup(did, 0);
|
||||
if (gg > 0) geosetGloves = static_cast<uint16_t>(301 + gg);
|
||||
}
|
||||
|
||||
geosets.insert(geosetGloves);
|
||||
geosets.insert(geosetBoots);
|
||||
geosets.insert(geosetChest);
|
||||
geosets.insert(geosetPants);
|
||||
geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited)
|
||||
if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle
|
||||
|
||||
// Hide hair under helmets (helmets are separate models; this still avoids hair clipping)
|
||||
if (hasInvType({1})) {
|
||||
geosets.erase(static_cast<uint16_t>(100 + hairStyle_ + 1));
|
||||
geosets.insert(1); // Bald scalp cap
|
||||
geosets.insert(101); // Default group-1 connector
|
||||
}
|
||||
|
||||
charRenderer_->setActiveGeosets(instanceId_, geosets);
|
||||
|
||||
// --- Textures (equipment overlays onto body skin) ---
|
||||
if (bodySkinPath_.empty()) return true; // geosets applied, but can't composite
|
||||
|
||||
static const char* componentDirs[] = {
|
||||
"ArmUpperTexture", "ArmLowerTexture", "HandTexture",
|
||||
"TorsoUpperTexture", "TorsoLowerTexture",
|
||||
"LegUpperTexture", "LegLowerTexture", "FootTexture",
|
||||
};
|
||||
|
||||
std::vector<std::pair<int, std::string>> regionLayers;
|
||||
regionLayers.reserve(32);
|
||||
|
||||
for (const auto& it : equipment) {
|
||||
if (it.displayModel == 0) continue;
|
||||
int32_t recIdx = displayInfoDbc->findRecordById(it.displayModel);
|
||||
if (recIdx < 0) continue;
|
||||
|
||||
for (int region = 0; region < 8; region++) {
|
||||
uint32_t fieldIdx = 15 + region; // texture_1..texture_8
|
||||
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
|
||||
if (texName.empty()) continue;
|
||||
|
||||
std::string base = "Item\\TextureComponents\\" +
|
||||
std::string(componentDirs[region]) + "\\" + texName;
|
||||
|
||||
std::string genderSuffix = (gender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp";
|
||||
std::string genderPath = base + genderSuffix;
|
||||
std::string unisexPath = base + "_U.blp";
|
||||
std::string fullPath;
|
||||
if (assetManager_->fileExists(genderPath)) {
|
||||
fullPath = genderPath;
|
||||
} else if (assetManager_->fileExists(unisexPath)) {
|
||||
fullPath = unisexPath;
|
||||
} else {
|
||||
fullPath = base + ".blp";
|
||||
}
|
||||
regionLayers.emplace_back(region, fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!regionLayers.empty()) {
|
||||
GLuint newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers);
|
||||
if (newTex != 0) {
|
||||
charRenderer_->setModelTexture(PREVIEW_MODEL_ID, skinTextureSlotIndex_, newTex);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void CharacterPreview::update(float deltaTime) {
|
||||
if (charRenderer_ && modelLoaded_) {
|
||||
charRenderer_->update(deltaTime);
|
||||
|
|
|
|||
|
|
@ -1257,16 +1257,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
// Bind VAO and draw
|
||||
glBindVertexArray(gpuModel.vao);
|
||||
|
||||
if (!gpuModel.data.batches.empty()) {
|
||||
bool applyGeosetFilter = !instance.activeGeosets.empty();
|
||||
if (applyGeosetFilter) {
|
||||
bool hasRenderableGeoset = false;
|
||||
for (const auto& batch : gpuModel.data.batches) {
|
||||
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
|
||||
hasRenderableGeoset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!gpuModel.data.batches.empty()) {
|
||||
bool applyGeosetFilter = !instance.activeGeosets.empty();
|
||||
if (applyGeosetFilter) {
|
||||
bool hasRenderableGeoset = false;
|
||||
for (const auto& batch : gpuModel.data.batches) {
|
||||
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
|
||||
hasRenderableGeoset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasRenderableGeoset) {
|
||||
static std::unordered_set<uint32_t> loggedGeosetFallback;
|
||||
if (loggedGeosetFallback.insert(instance.id).second) {
|
||||
|
|
@ -1274,13 +1274,64 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
instance.id, " (model ", instance.modelId,
|
||||
"); rendering all batches as fallback");
|
||||
}
|
||||
applyGeosetFilter = false;
|
||||
}
|
||||
}
|
||||
applyGeosetFilter = false;
|
||||
}
|
||||
}
|
||||
|
||||
// One-time debug dump of rendered batches per model
|
||||
static std::unordered_set<uint32_t> dumpedModels;
|
||||
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
|
||||
auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
|
||||
// A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex.
|
||||
// We currently bind only a single texture, so pick the most appropriate one.
|
||||
//
|
||||
// This matters for hair: the first texture in the combo can be a mask/empty slot,
|
||||
// causing the hair to render as solid white.
|
||||
if (b.textureIndex == 0xFFFF) return whiteTexture;
|
||||
if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture;
|
||||
|
||||
uint32_t comboCount = b.textureCount ? static_cast<uint32_t>(b.textureCount) : 1u;
|
||||
comboCount = std::min<uint32_t>(comboCount, 8u);
|
||||
|
||||
struct Candidate { GLuint id; uint32_t type; };
|
||||
Candidate first{whiteTexture, 0};
|
||||
bool hasFirst = false;
|
||||
Candidate firstNonWhite{whiteTexture, 0};
|
||||
bool hasFirstNonWhite = false;
|
||||
|
||||
for (uint32_t i = 0; i < comboCount; i++) {
|
||||
uint32_t lookupPos = static_cast<uint32_t>(b.textureIndex) + i;
|
||||
if (lookupPos >= gm.data.textureLookup.size()) break;
|
||||
uint16_t texSlot = gm.data.textureLookup[lookupPos];
|
||||
if (texSlot >= gm.textureIds.size()) continue;
|
||||
|
||||
GLuint texId = gm.textureIds[texSlot];
|
||||
uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0;
|
||||
|
||||
if (!hasFirst) {
|
||||
first = {texId, texType};
|
||||
hasFirst = true;
|
||||
}
|
||||
|
||||
if (texId == 0 || texId == whiteTexture) continue;
|
||||
|
||||
// Prefer the hair texture slot (type 6) whenever present in the combo.
|
||||
// Humanoid scalp meshes can live in group 0, so group-based checks are insufficient.
|
||||
if (texType == 6) {
|
||||
return texId;
|
||||
}
|
||||
|
||||
if (!hasFirstNonWhite) {
|
||||
firstNonWhite = {texId, texType};
|
||||
hasFirstNonWhite = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFirstNonWhite) return firstNonWhite.id;
|
||||
if (hasFirst && first.id != 0) return first.id;
|
||||
return whiteTexture;
|
||||
};
|
||||
|
||||
// One-time debug dump of rendered batches per model
|
||||
static std::unordered_set<uint32_t> dumpedModels;
|
||||
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
|
||||
dumpedModels.insert(instance.modelId);
|
||||
int bIdx = 0;
|
||||
int rendered = 0, skipped = 0;
|
||||
|
|
@ -1289,24 +1340,11 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
(b.submeshId / 100 != 0) &&
|
||||
instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end();
|
||||
|
||||
GLuint resolvedTex = whiteTexture;
|
||||
std::string texInfo = "white(fallback)";
|
||||
if (b.textureIndex != 0xFFFF && b.textureIndex < gpuModel.data.textureLookup.size()) {
|
||||
uint16_t lk = gpuModel.data.textureLookup[b.textureIndex];
|
||||
if (lk < gpuModel.textureIds.size()) {
|
||||
resolvedTex = gpuModel.textureIds[lk];
|
||||
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->tex[" + std::to_string(lk) + "]=GL" + std::to_string(resolvedTex);
|
||||
} else {
|
||||
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->OOB(" + std::to_string(lk) + ")";
|
||||
}
|
||||
} else if (b.textureIndex == 0xFFFF) {
|
||||
texInfo = "texIdx=FFFF";
|
||||
} else {
|
||||
texInfo = "texIdx=" + std::to_string(b.textureIndex) + " OOB(lookupSz=" + std::to_string(gpuModel.data.textureLookup.size()) + ")";
|
||||
}
|
||||
GLuint resolvedTex = resolveBatchTexture(gpuModel, b);
|
||||
std::string texInfo = "GL" + std::to_string(resolvedTex);
|
||||
|
||||
if (filtered) skipped++; else rendered++;
|
||||
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
|
||||
if (filtered) skipped++; else rendered++;
|
||||
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
|
||||
" level=", b.submeshLevel,
|
||||
" idxStart=", b.indexStart, " idxCount=", b.indexCount,
|
||||
" tex=", texInfo,
|
||||
|
|
@ -1317,28 +1355,22 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
|
|||
gpuModel.textureIds.size(), " textures loaded, ",
|
||||
gpuModel.data.textureLookup.size(), " in lookup table");
|
||||
for (size_t t = 0; t < gpuModel.data.textures.size(); t++) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw batches (submeshes) with per-batch textures
|
||||
// Geoset filtering: skip batches whose submeshId is not in activeGeosets.
|
||||
// For character models, group 0 (body/scalp) is also filtered so that only
|
||||
// the correct scalp mesh renders (not all overlapping variants).
|
||||
for (const auto& batch : gpuModel.data.batches) {
|
||||
// Draw batches (submeshes) with per-batch textures
|
||||
// Geoset filtering: skip batches whose submeshId is not in activeGeosets.
|
||||
// For character models, group 0 (body/scalp) is also filtered so that only
|
||||
// the correct scalp mesh renders (not all overlapping variants).
|
||||
for (const auto& batch : gpuModel.data.batches) {
|
||||
if (applyGeosetFilter) {
|
||||
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve texture for this batch
|
||||
GLuint texId = whiteTexture;
|
||||
if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
|
||||
uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex];
|
||||
if (lookupIdx < gpuModel.textureIds.size()) {
|
||||
texId = gpuModel.textureIds[lookupIdx];
|
||||
}
|
||||
}
|
||||
// Resolve texture for this batch (prefer hair textures for hair geosets).
|
||||
GLuint texId = resolveBatchTexture(gpuModel, batch);
|
||||
|
||||
// For body parts with white/fallback texture, use skin (type 1) texture
|
||||
// This handles humanoid models where some body parts use different texture slots
|
||||
|
|
|
|||
|
|
@ -1231,7 +1231,12 @@ void Renderer::updateCharacterAnimation() {
|
|||
// Keep seat offset minimal; large offsets amplify visible bobble.
|
||||
glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f);
|
||||
glm::vec3 targetRiderPos = mountSeatPos + seatOffset;
|
||||
if (!mountSeatSmoothingInit_) {
|
||||
// When moving, smoothing the seat position produces visible lag that looks like
|
||||
// the rider sliding toward the rump. Anchor rigidly while moving.
|
||||
if (moving) {
|
||||
mountSeatSmoothingInit_ = false;
|
||||
smoothedMountSeatPos_ = targetRiderPos;
|
||||
} else if (!mountSeatSmoothingInit_) {
|
||||
smoothedMountSeatPos_ = targetRiderPos;
|
||||
mountSeatSmoothingInit_ = true;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ void CharacterCreateScreen::reset() {
|
|||
maxFacialHair = 8;
|
||||
statusMessage.clear();
|
||||
statusIsError = false;
|
||||
hairColorIds_.clear();
|
||||
updateAvailableClasses();
|
||||
|
||||
// Reset preview tracking to force model reload on next render
|
||||
|
|
@ -114,13 +115,19 @@ void CharacterCreateScreen::updatePreviewIfNeeded() {
|
|||
|
||||
if (changed) {
|
||||
bool useFemaleModel = (genderIndex == 2 && bodyTypeIndex == 1); // Nonbinary + Feminine
|
||||
uint8_t hairColorId = 0;
|
||||
if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast<int>(hairColorIds_.size())) {
|
||||
hairColorId = hairColorIds_[hairColor];
|
||||
} else {
|
||||
hairColorId = static_cast<uint8_t>(hairColor);
|
||||
}
|
||||
preview_->loadCharacter(
|
||||
allRaces[raceIndex],
|
||||
static_cast<game::Gender>(genderIndex),
|
||||
static_cast<uint8_t>(skin),
|
||||
static_cast<uint8_t>(face),
|
||||
static_cast<uint8_t>(hairStyle),
|
||||
static_cast<uint8_t>(hairColor),
|
||||
hairColorId,
|
||||
static_cast<uint8_t>(facialHair),
|
||||
useFemaleModel);
|
||||
|
||||
|
|
@ -153,6 +160,7 @@ void CharacterCreateScreen::updateAppearanceRanges() {
|
|||
maxHairStyle = 11;
|
||||
maxHairColor = 9;
|
||||
maxFacialHair = 8;
|
||||
hairColorIds_.clear();
|
||||
|
||||
if (!assetManager_) return;
|
||||
auto dbc = assetManager_->loadDBC("CharSections.dbc");
|
||||
|
|
@ -189,7 +197,7 @@ void CharacterCreateScreen::updateAppearanceRanges() {
|
|||
}
|
||||
|
||||
int faceMax = -1;
|
||||
int hairColorMax = -1;
|
||||
std::vector<uint8_t> hairColorIds;
|
||||
for (uint32_t r = 0; r < dbc->getRecordCount(); r++) {
|
||||
uint32_t raceId = dbc->getUInt32(r, 1);
|
||||
uint32_t sexId = dbc->getUInt32(r, 2);
|
||||
|
|
@ -202,7 +210,9 @@ void CharacterCreateScreen::updateAppearanceRanges() {
|
|||
if (baseSection == 1 && colorIndex == static_cast<uint32_t>(skin)) {
|
||||
faceMax = std::max(faceMax, static_cast<int>(variationIndex));
|
||||
} else if (baseSection == 3 && variationIndex == static_cast<uint32_t>(hairStyle)) {
|
||||
hairColorMax = std::max(hairColorMax, static_cast<int>(colorIndex));
|
||||
if (colorIndex <= 255) {
|
||||
hairColorIds.push_back(static_cast<uint8_t>(colorIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,9 +220,15 @@ void CharacterCreateScreen::updateAppearanceRanges() {
|
|||
maxFace = faceMax;
|
||||
if (face > maxFace) face = maxFace;
|
||||
}
|
||||
if (hairColorMax >= 0) {
|
||||
maxHairColor = hairColorMax;
|
||||
|
||||
// Hair colors: use actual available DBC IDs (not "0..maxId"), since IDs may be sparse.
|
||||
if (!hairColorIds.empty()) {
|
||||
std::sort(hairColorIds.begin(), hairColorIds.end());
|
||||
hairColorIds.erase(std::unique(hairColorIds.begin(), hairColorIds.end()), hairColorIds.end());
|
||||
hairColorIds_ = std::move(hairColorIds);
|
||||
maxHairColor = std::max(0, static_cast<int>(hairColorIds_.size()) - 1);
|
||||
if (hairColor > maxHairColor) hairColor = maxHairColor;
|
||||
if (hairColor < 0) hairColor = 0;
|
||||
}
|
||||
int facialMax = -1;
|
||||
auto facialDbc = assetManager_->loadDBC("CharacterFacialHairStyles.dbc");
|
||||
|
|
@ -450,7 +466,11 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
|||
data.skin = static_cast<uint8_t>(skin);
|
||||
data.face = static_cast<uint8_t>(face);
|
||||
data.hairStyle = static_cast<uint8_t>(hairStyle);
|
||||
data.hairColor = static_cast<uint8_t>(hairColor);
|
||||
if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast<int>(hairColorIds_.size())) {
|
||||
data.hairColor = hairColorIds_[hairColor];
|
||||
} else {
|
||||
data.hairColor = static_cast<uint8_t>(hairColor);
|
||||
}
|
||||
data.facialHair = static_cast<uint8_t>(facialHair);
|
||||
if (onCreate) {
|
||||
onCreate(data);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
#include "ui/character_screen.hpp"
|
||||
#include "rendering/character_preview.hpp"
|
||||
#include "pipeline/asset_manager.hpp"
|
||||
#include "core/application.hpp"
|
||||
#include "core/logger.hpp"
|
||||
#include <imgui.h>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
|
|
@ -11,26 +15,59 @@ namespace wowee { namespace ui {
|
|||
CharacterScreen::CharacterScreen() {
|
||||
}
|
||||
|
||||
void CharacterScreen::render(game::GameHandler& gameHandler) {
|
||||
// Size the window to fill most of the viewport
|
||||
ImVec2 vpSize = ImGui::GetMainViewport()->Size;
|
||||
ImVec2 winSize(vpSize.x * 0.6f, vpSize.y * 0.7f);
|
||||
if (winSize.x < 700.0f) winSize.x = 700.0f;
|
||||
if (winSize.y < 500.0f) winSize.y = 500.0f;
|
||||
ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowPos(
|
||||
ImVec2(vpSize.x * 0.5f, vpSize.y * 0.5f),
|
||||
ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
|
||||
static uint64_t hashEquipment(const std::vector<game::EquipmentItem>& eq) {
|
||||
// FNV-1a 64-bit over (displayModel, inventoryType, enchantment)
|
||||
uint64_t h = 1469598103934665603ull;
|
||||
auto mix8 = [&](uint8_t b) {
|
||||
h ^= b;
|
||||
h *= 1099511628211ull;
|
||||
};
|
||||
auto mix32 = [&](uint32_t v) {
|
||||
mix8(static_cast<uint8_t>(v & 0xFF));
|
||||
mix8(static_cast<uint8_t>((v >> 8) & 0xFF));
|
||||
mix8(static_cast<uint8_t>((v >> 16) & 0xFF));
|
||||
mix8(static_cast<uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
for (const auto& it : eq) {
|
||||
mix32(it.displayModel);
|
||||
mix8(it.inventoryType);
|
||||
mix32(it.enchantment);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse);
|
||||
void CharacterScreen::render(game::GameHandler& gameHandler) {
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
const ImVec2 pad(24.0f, 24.0f);
|
||||
ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f);
|
||||
if (winSize.x < 860.0f) winSize.x = 860.0f;
|
||||
if (winSize.y < 620.0f) winSize.y = 620.0f;
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f,
|
||||
vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f),
|
||||
ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(winSize, ImGuiCond_Always);
|
||||
|
||||
ImGui::Begin("Character Selection", nullptr,
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove);
|
||||
|
||||
// Ensure we can render a preview even if the state transition hook didn't inject the AssetManager.
|
||||
if (!assetManager_) {
|
||||
assetManager_ = core::Application::getInstance().getAssetManager();
|
||||
}
|
||||
|
||||
// Get character list
|
||||
const auto& characters = gameHandler.getCharacters();
|
||||
|
||||
// Request character list if not available
|
||||
if (characters.empty() && gameHandler.getState() == game::WorldState::READY) {
|
||||
// Request character list if not available.
|
||||
// Also show a loading state while CHAR_LIST_REQUESTED is in-flight (characters may be cleared to avoid stale UI).
|
||||
if (characters.empty() &&
|
||||
(gameHandler.getState() == game::WorldState::READY ||
|
||||
gameHandler.getState() == game::WorldState::CHAR_LIST_REQUESTED)) {
|
||||
ImGui::Text("Loading characters...");
|
||||
gameHandler.requestCharacterList();
|
||||
if (gameHandler.getState() == game::WorldState::READY) {
|
||||
gameHandler.requestCharacterList();
|
||||
}
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
|
@ -54,6 +91,22 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
|
|||
return;
|
||||
}
|
||||
|
||||
// If the list refreshed, keep selection stable by GUID.
|
||||
if (selectedCharacterGuid != 0) {
|
||||
const bool needReselect =
|
||||
(selectedCharacterIndex < 0) ||
|
||||
(selectedCharacterIndex >= static_cast<int>(characters.size())) ||
|
||||
(characters[static_cast<size_t>(selectedCharacterIndex)].guid != selectedCharacterGuid);
|
||||
if (needReselect) {
|
||||
for (size_t i = 0; i < characters.size(); ++i) {
|
||||
if (characters[i].guid == selectedCharacterGuid) {
|
||||
selectedCharacterIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore last-selected character (once per screen visit)
|
||||
if (!restoredLastCharacter) {
|
||||
// Priority 1: Select newly created character if set
|
||||
|
|
@ -99,7 +152,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
|
|||
|
||||
// ── Two-column layout: character list (left) | details (right) ──
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float detailPanelW = 260.0f;
|
||||
float detailPanelW = 360.0f;
|
||||
float listW = availW - detailPanelW - ImGui::GetStyle().ItemSpacing.x;
|
||||
if (listW < 300.0f) { listW = availW; detailPanelW = 0.0f; }
|
||||
|
||||
|
|
@ -174,9 +227,83 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
|
|||
|
||||
const auto& character = characters[selectedCharacterIndex];
|
||||
|
||||
// Keep the 3D preview in sync with the selected character.
|
||||
if (assetManager_ && assetManager_->isInitialized()) {
|
||||
if (!preview_) {
|
||||
preview_ = std::make_unique<rendering::CharacterPreview>();
|
||||
}
|
||||
if (!previewInitialized_) {
|
||||
previewInitialized_ = preview_->initialize(assetManager_);
|
||||
if (!previewInitialized_) {
|
||||
LOG_WARNING("CharacterScreen: failed to init CharacterPreview");
|
||||
preview_.reset();
|
||||
}
|
||||
}
|
||||
if (preview_) {
|
||||
const uint64_t equipHash = hashEquipment(character.equipment);
|
||||
const bool changed =
|
||||
(previewGuid_ != character.guid) ||
|
||||
(previewAppearanceBytes_ != character.appearanceBytes) ||
|
||||
(previewFacialFeatures_ != character.facialFeatures) ||
|
||||
(previewUseFemaleModel_ != character.useFemaleModel) ||
|
||||
(previewEquipHash_ != equipHash) ||
|
||||
(!preview_->isModelLoaded());
|
||||
|
||||
if (changed) {
|
||||
uint8_t skin = character.appearanceBytes & 0xFF;
|
||||
uint8_t face = (character.appearanceBytes >> 8) & 0xFF;
|
||||
uint8_t hairStyle = (character.appearanceBytes >> 16) & 0xFF;
|
||||
uint8_t hairColor = (character.appearanceBytes >> 24) & 0xFF;
|
||||
|
||||
if (preview_->loadCharacter(character.race, character.gender,
|
||||
skin, face, hairStyle, hairColor,
|
||||
character.facialFeatures, character.useFemaleModel)) {
|
||||
preview_->applyEquipment(character.equipment);
|
||||
}
|
||||
|
||||
previewGuid_ = character.guid;
|
||||
previewAppearanceBytes_ = character.appearanceBytes;
|
||||
previewFacialFeatures_ = character.facialFeatures;
|
||||
previewUseFemaleModel_ = character.useFemaleModel;
|
||||
previewEquipHash_ = equipHash;
|
||||
}
|
||||
|
||||
// Drive preview animation and render to its FBO.
|
||||
preview_->update(ImGui::GetIO().DeltaTime);
|
||||
preview_->render();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true);
|
||||
|
||||
// 3D preview portrait
|
||||
if (preview_ && preview_->getTextureId() != 0) {
|
||||
float imgW = ImGui::GetContentRegionAvail().x;
|
||||
float imgH = imgW * (static_cast<float>(preview_->getHeight()) /
|
||||
static_cast<float>(preview_->getWidth()));
|
||||
// Clamp to avoid taking the entire panel
|
||||
float maxH = 320.0f;
|
||||
if (imgH > maxH) {
|
||||
imgH = maxH;
|
||||
imgW = imgH * (static_cast<float>(preview_->getWidth()) /
|
||||
static_cast<float>(preview_->getHeight()));
|
||||
}
|
||||
ImGui::Image(
|
||||
static_cast<ImTextureID>(preview_->getTextureId()),
|
||||
ImVec2(imgW, imgH),
|
||||
ImVec2(0.0f, 1.0f), // flip Y for OpenGL
|
||||
ImVec2(1.0f, 0.0f));
|
||||
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
|
||||
preview_->rotate(ImGui::GetIO().MouseDelta.x * 0.2f);
|
||||
}
|
||||
ImGui::Spacing();
|
||||
} else if (!assetManager_ || !assetManager_->isInitialized()) {
|
||||
ImGui::TextDisabled("Preview unavailable (assets not loaded)");
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str());
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
|
|
|||
|
|
@ -2201,8 +2201,20 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
|
|||
for (uint16_t i = 0; i <= 18; i++) {
|
||||
geosets.insert(i);
|
||||
}
|
||||
geosets.insert(101); // Hair
|
||||
geosets.insert(201); // Facial
|
||||
// Hair/facial geosets must match the active character's appearance, otherwise
|
||||
// we end up forcing a default hair mesh (often perceived as "wrong hair").
|
||||
{
|
||||
uint8_t hairStyleId = 0;
|
||||
uint8_t facialId = 0;
|
||||
if (auto* gh = app.getGameHandler()) {
|
||||
if (const auto* ch = gh->getActiveCharacter()) {
|
||||
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
|
||||
facialId = ch->facialFeatures;
|
||||
}
|
||||
}
|
||||
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair
|
||||
geosets.insert(static_cast<uint16_t>(200 + facialId + 1)); // Group 2 facial
|
||||
}
|
||||
geosets.insert(701); // Ears
|
||||
|
||||
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe)
|
||||
|
|
|
|||
|
|
@ -1105,7 +1105,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
pickupFromBackpack(inventory, backpackIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT) {
|
||||
pickupFromEquipment(inventory, equipSlot);
|
||||
if (gameHandler_) {
|
||||
// Online mode: don't mutate local equipment state.
|
||||
game::MessageChatData msg{};
|
||||
msg.type = game::ChatType::SYSTEM;
|
||||
msg.language = game::ChatLanguage::UNIVERSAL;
|
||||
msg.message = "Moving equipped items not supported yet (online mode).";
|
||||
gameHandler_->addLocalChatMessage(msg);
|
||||
} else {
|
||||
pickupFromEquipment(inventory, equipSlot);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
|
|
@ -1126,13 +1135,19 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
// Sell to vendor
|
||||
gameHandler_->sellItemBySlot(backpackIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT) {
|
||||
// Unequip: move to free backpack slot
|
||||
int freeSlot = inventory.findFreeBackpackSlot();
|
||||
if (freeSlot >= 0) {
|
||||
inventory.setBackpackSlot(freeSlot, item);
|
||||
inventory.clearEquipSlot(equipSlot);
|
||||
equipmentDirty = true;
|
||||
inventoryDirty = true;
|
||||
if (gameHandler_) {
|
||||
// Online mode: request server-side unequip (move to first free backpack slot).
|
||||
LOG_INFO("UI unequip request: equipSlot=", (int)equipSlot);
|
||||
gameHandler_->unequipToBackpack(equipSlot);
|
||||
} else {
|
||||
// Offline mode: Unequip: move to free backpack slot
|
||||
int freeSlot = inventory.findFreeBackpackSlot();
|
||||
if (freeSlot >= 0) {
|
||||
inventory.setBackpackSlot(freeSlot, item);
|
||||
inventory.clearEquipSlot(equipSlot);
|
||||
equipmentDirty = true;
|
||||
inventoryDirty = true;
|
||||
}
|
||||
}
|
||||
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
if (gameHandler_) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,18 @@ RealmScreen::RealmScreen() {
|
|||
}
|
||||
|
||||
void RealmScreen::render(auth::AuthHandler& authHandler) {
|
||||
ImGui::SetNextWindowSize(ImVec2(700, 500), ImGuiCond_FirstUseEver);
|
||||
ImGui::Begin("Realm Selection", nullptr, ImGuiWindowFlags_NoCollapse);
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
const ImVec2 pad(24.0f, 24.0f);
|
||||
ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f);
|
||||
if (winSize.x < 720.0f) winSize.x = 720.0f;
|
||||
if (winSize.y < 540.0f) winSize.y = 540.0f;
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f,
|
||||
vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f),
|
||||
ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(winSize, ImGuiCond_Always);
|
||||
ImGui::Begin("Realm Selection", nullptr,
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove);
|
||||
|
||||
ImGui::Text("Select a Realm");
|
||||
ImGui::Separator();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue