mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 09:33:51 +00:00
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets) - Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing - Fix stale character data between logins by replacing static init flag with per-character GUID tracking - Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer) - Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes - Reduce camera ground samples from 5 to 3 movement-aligned probes - Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
This commit is contained in:
parent
ca88860929
commit
751e6fdbde
11 changed files with 741 additions and 307 deletions
|
|
@ -324,6 +324,10 @@ public:
|
||||||
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
|
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
|
||||||
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
|
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
|
||||||
|
|
||||||
|
void saveCharacterConfig();
|
||||||
|
void loadCharacterConfig();
|
||||||
|
static std::string getCharacterConfigDir();
|
||||||
|
|
||||||
// Auras
|
// Auras
|
||||||
const std::vector<AuraSlot>& getPlayerAuras() const { return playerAuras; }
|
const std::vector<AuraSlot>& getPlayerAuras() const { return playerAuras; }
|
||||||
const std::vector<AuraSlot>& getTargetAuras() const { return targetAuras; }
|
const std::vector<AuraSlot>& getTargetAuras() const { return targetAuras; }
|
||||||
|
|
@ -449,7 +453,7 @@ public:
|
||||||
// Vendor
|
// Vendor
|
||||||
void openVendor(uint64_t npcGuid);
|
void openVendor(uint64_t npcGuid);
|
||||||
void closeVendor();
|
void closeVendor();
|
||||||
void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count);
|
void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count);
|
||||||
void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count);
|
void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count);
|
||||||
void sellItemBySlot(int backpackIndex);
|
void sellItemBySlot(int backpackIndex);
|
||||||
void autoEquipItemBySlot(int backpackIndex);
|
void autoEquipItemBySlot(int backpackIndex);
|
||||||
|
|
|
||||||
|
|
@ -1637,7 +1637,7 @@ public:
|
||||||
/** CMSG_BUY_ITEM packet builder */
|
/** CMSG_BUY_ITEM packet builder */
|
||||||
class BuyItemPacket {
|
class BuyItemPacket {
|
||||||
public:
|
public:
|
||||||
static network::Packet build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count);
|
static network::Packet build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** CMSG_SELL_ITEM packet builder */
|
/** CMSG_SELL_ITEM packet builder */
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,8 @@ private:
|
||||||
glm::vec3 boundingBoxMin;
|
glm::vec3 boundingBoxMin;
|
||||||
glm::vec3 boundingBoxMax;
|
glm::vec3 boundingBoxMax;
|
||||||
|
|
||||||
|
uint32_t groupFlags = 0;
|
||||||
|
|
||||||
// Material batches (start index, count, material ID)
|
// Material batches (start index, count, material ID)
|
||||||
struct Batch {
|
struct Batch {
|
||||||
uint32_t startIndex; // First index in EBO
|
uint32_t startIndex; // First index in EBO
|
||||||
|
|
@ -258,6 +260,8 @@ private:
|
||||||
GLuint texId;
|
GLuint texId;
|
||||||
bool hasTexture;
|
bool hasTexture;
|
||||||
bool alphaTest;
|
bool alphaTest;
|
||||||
|
bool unlit = false;
|
||||||
|
uint32_t blendMode = 0;
|
||||||
std::vector<GLsizei> counts;
|
std::vector<GLsizei> counts;
|
||||||
std::vector<const void*> offsets;
|
std::vector<const void*> offsets;
|
||||||
};
|
};
|
||||||
|
|
@ -302,6 +306,9 @@ private:
|
||||||
// Material blend modes (materialId -> blendMode; 1 = alpha-test cutout)
|
// Material blend modes (materialId -> blendMode; 1 = alpha-test cutout)
|
||||||
std::vector<uint32_t> materialBlendModes;
|
std::vector<uint32_t> materialBlendModes;
|
||||||
|
|
||||||
|
// Material flags (materialId -> flags; 0x01 = unlit)
|
||||||
|
std::vector<uint32_t> materialFlags;
|
||||||
|
|
||||||
// Portal visibility data
|
// Portal visibility data
|
||||||
std::vector<PortalData> portals;
|
std::vector<PortalData> portals;
|
||||||
std::vector<glm::vec3> portalVertices;
|
std::vector<glm::vec3> portalVertices;
|
||||||
|
|
@ -339,7 +346,7 @@ private:
|
||||||
/**
|
/**
|
||||||
* Create GPU resources for a WMO group
|
* Create GPU resources for a WMO group
|
||||||
*/
|
*/
|
||||||
bool createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources);
|
bool createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources, uint32_t groupFlags = 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single group
|
* Render a single group
|
||||||
|
|
@ -492,6 +499,17 @@ private:
|
||||||
mutable std::vector<size_t> candidateScratch;
|
mutable std::vector<size_t> candidateScratch;
|
||||||
mutable std::unordered_set<uint32_t> candidateIdScratch;
|
mutable std::unordered_set<uint32_t> candidateIdScratch;
|
||||||
|
|
||||||
|
// Parallel visibility culling
|
||||||
|
uint32_t numCullThreads_ = 1;
|
||||||
|
|
||||||
|
struct InstanceDrawList {
|
||||||
|
size_t instanceIndex;
|
||||||
|
std::vector<uint32_t> visibleGroups; // group indices that passed culling
|
||||||
|
uint32_t portalCulled = 0;
|
||||||
|
uint32_t distanceCulled = 0;
|
||||||
|
uint32_t occlusionCulled = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Collision query profiling (per frame).
|
// Collision query profiling (per frame).
|
||||||
mutable double queryTimeMs = 0.0;
|
mutable double queryTimeMs = 0.0;
|
||||||
mutable uint32_t queryCallCount = 0;
|
mutable uint32_t queryCallCount = 0;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ private:
|
||||||
int selectedCharacterIndex = -1;
|
int selectedCharacterIndex = -1;
|
||||||
bool characterSelected = false;
|
bool characterSelected = false;
|
||||||
uint64_t selectedCharacterGuid = 0;
|
uint64_t selectedCharacterGuid = 0;
|
||||||
|
bool restoredLastCharacter = false;
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
|
|
@ -69,6 +70,13 @@ private:
|
||||||
* Get faction color based on race
|
* Get faction color based on race
|
||||||
*/
|
*/
|
||||||
ImVec4 getFactionColor(game::Race race) const;
|
ImVec4 getFactionColor(game::Race race) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist / restore last selected character GUID
|
||||||
|
*/
|
||||||
|
static std::string getConfigDir();
|
||||||
|
void saveLastCharacter(uint64_t guid);
|
||||||
|
uint64_t loadLastCharacter();
|
||||||
};
|
};
|
||||||
|
|
||||||
}} // namespace wowee::ui
|
}} // namespace wowee::ui
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ public:
|
||||||
*/
|
*/
|
||||||
bool isChatInputActive() const { return chatInputActive; }
|
bool isChatInputActive() const { return chatInputActive; }
|
||||||
|
|
||||||
|
void saveSettings();
|
||||||
|
void loadSettings();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Chat state
|
// Chat state
|
||||||
char chatInputBuffer[512] = "";
|
char chatInputBuffer[512] = "";
|
||||||
|
|
@ -66,10 +69,10 @@ private:
|
||||||
int pendingSfxVolume = 100;
|
int pendingSfxVolume = 100;
|
||||||
float pendingMouseSensitivity = 0.2f;
|
float pendingMouseSensitivity = 0.2f;
|
||||||
bool pendingInvertMouse = false;
|
bool pendingInvertMouse = false;
|
||||||
int pendingUiOpacity = 100;
|
int pendingUiOpacity = 65;
|
||||||
|
|
||||||
// UI element transparency (0.0 = fully transparent, 1.0 = fully opaque)
|
// UI element transparency (0.0 = fully transparent, 1.0 = fully opaque)
|
||||||
float uiOpacity_ = 1.0f;
|
float uiOpacity_ = 0.65f;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render player info window
|
* Render player info window
|
||||||
|
|
@ -152,6 +155,7 @@ private:
|
||||||
void renderWorldMap(game::GameHandler& gameHandler);
|
void renderWorldMap(game::GameHandler& gameHandler);
|
||||||
|
|
||||||
InventoryScreen inventoryScreen;
|
InventoryScreen inventoryScreen;
|
||||||
|
uint64_t inventoryScreenCharGuid_ = 0; // GUID of character inventory screen was initialized for
|
||||||
QuestLogScreen questLogScreen;
|
QuestLogScreen questLogScreen;
|
||||||
SpellbookScreen spellbookScreen;
|
SpellbookScreen spellbookScreen;
|
||||||
TalentScreen talentScreen;
|
TalentScreen talentScreen;
|
||||||
|
|
@ -174,6 +178,8 @@ private:
|
||||||
int actionBarDragSlot_ = -1;
|
int actionBarDragSlot_ = -1;
|
||||||
GLuint actionBarDragIcon_ = 0;
|
GLuint actionBarDragIcon_ = 0;
|
||||||
|
|
||||||
|
static std::string getSettingsPath();
|
||||||
|
|
||||||
// Left-click targeting: distinguish click from camera drag
|
// Left-click targeting: distinguish click from camera drag
|
||||||
glm::vec2 leftClickPressPos_ = glm::vec2(0.0f);
|
glm::vec2 leftClickPressPos_ = glm::vec2(0.0f);
|
||||||
bool leftClickWasPress_ = false;
|
bool leftClickWasPress_ = false;
|
||||||
|
|
|
||||||
|
|
@ -903,6 +903,43 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
|
||||||
// Store player GUID
|
// Store player GUID
|
||||||
playerGuid = characterGuid;
|
playerGuid = characterGuid;
|
||||||
|
|
||||||
|
// Reset per-character state so previous character data doesn't bleed through
|
||||||
|
inventory = Inventory();
|
||||||
|
onlineItems_.clear();
|
||||||
|
pendingItemQueries_.clear();
|
||||||
|
equipSlotGuids_ = {};
|
||||||
|
backpackSlotGuids_ = {};
|
||||||
|
invSlotBase_ = -1;
|
||||||
|
packSlotBase_ = -1;
|
||||||
|
lastPlayerFields_.clear();
|
||||||
|
onlineEquipDirty_ = false;
|
||||||
|
playerMoneyCopper_ = 0;
|
||||||
|
knownSpells.clear();
|
||||||
|
spellCooldowns.clear();
|
||||||
|
actionBar = {};
|
||||||
|
playerAuras.clear();
|
||||||
|
targetAuras.clear();
|
||||||
|
playerXp_ = 0;
|
||||||
|
playerNextLevelXp_ = 0;
|
||||||
|
serverPlayerLevel_ = 1;
|
||||||
|
playerSkills_.clear();
|
||||||
|
questLog_.clear();
|
||||||
|
npcQuestStatus_.clear();
|
||||||
|
hostileAttackers_.clear();
|
||||||
|
combatText.clear();
|
||||||
|
autoAttacking = false;
|
||||||
|
autoAttackTarget = 0;
|
||||||
|
casting = false;
|
||||||
|
currentCastSpellId = 0;
|
||||||
|
castTimeRemaining = 0.0f;
|
||||||
|
castTimeTotal = 0.0f;
|
||||||
|
playerDead_ = false;
|
||||||
|
targetGuid = 0;
|
||||||
|
focusGuid = 0;
|
||||||
|
lastTargetGuid = 0;
|
||||||
|
tabCycleStale = true;
|
||||||
|
entityManager = EntityManager();
|
||||||
|
|
||||||
// Build CMSG_PLAYER_LOGIN packet
|
// Build CMSG_PLAYER_LOGIN packet
|
||||||
auto packet = PlayerLoginPacket::build(characterGuid);
|
auto packet = PlayerLoginPacket::build(characterGuid);
|
||||||
|
|
||||||
|
|
@ -3058,6 +3095,7 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t
|
||||||
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
|
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
|
||||||
actionBar[slot].type = type;
|
actionBar[slot].type = type;
|
||||||
actionBar[slot].id = id;
|
actionBar[slot].id = id;
|
||||||
|
saveCharacterConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
float GameHandler::getSpellCooldown(uint32_t spellId) const {
|
float GameHandler::getSpellCooldown(uint32_t spellId) const {
|
||||||
|
|
@ -3086,11 +3124,12 @@ void GameHandler::handleInitialSpells(network::Packet& packet) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-populate action bar: Attack in slot 1, Hearthstone in slot 12
|
// Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12)
|
||||||
actionBar[0].type = ActionBarSlot::SPELL;
|
actionBar[0].type = ActionBarSlot::SPELL;
|
||||||
actionBar[0].id = 6603; // Attack
|
actionBar[0].id = 6603; // Attack
|
||||||
actionBar[11].type = ActionBarSlot::SPELL;
|
actionBar[11].type = ActionBarSlot::SPELL;
|
||||||
actionBar[11].id = 8690; // Hearthstone
|
actionBar[11].id = 8690; // Hearthstone
|
||||||
|
loadCharacterConfig();
|
||||||
|
|
||||||
LOG_INFO("Learned ", knownSpells.size(), " spells");
|
LOG_INFO("Learned ", knownSpells.size(), " spells");
|
||||||
}
|
}
|
||||||
|
|
@ -3502,7 +3541,7 @@ void GameHandler::closeVendor() {
|
||||||
currentVendorItems = ListInventoryData{};
|
currentVendorItems = ListInventoryData{};
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) {
|
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
|
||||||
if (state != WorldState::IN_WORLD || !socket) return;
|
if (state != WorldState::IN_WORLD || !socket) return;
|
||||||
auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count);
|
auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count);
|
||||||
socket->send(packet);
|
socket->send(packet);
|
||||||
|
|
@ -4060,5 +4099,98 @@ void GameHandler::extractSkillFields(const std::map<uint16_t, uint32_t>& fields)
|
||||||
playerSkills_ = std::move(newSkills);
|
playerSkills_ = std::move(newSkills);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string GameHandler::getCharacterConfigDir() {
|
||||||
|
std::string dir;
|
||||||
|
#ifdef _WIN32
|
||||||
|
const char* appdata = std::getenv("APPDATA");
|
||||||
|
dir = appdata ? std::string(appdata) + "\\wowee\\characters" : "characters";
|
||||||
|
#else
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
dir = home ? std::string(home) + "/.wowee/characters" : "characters";
|
||||||
|
#endif
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameHandler::saveCharacterConfig() {
|
||||||
|
const Character* ch = getActiveCharacter();
|
||||||
|
if (!ch || ch->name.empty()) return;
|
||||||
|
|
||||||
|
std::string dir = getCharacterConfigDir();
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
|
||||||
|
std::string path = dir + "/" + ch->name + ".cfg";
|
||||||
|
std::ofstream out(path);
|
||||||
|
if (!out.is_open()) {
|
||||||
|
LOG_WARNING("Could not save character config to ", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out << "character_guid=" << playerGuid << "\n";
|
||||||
|
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
||||||
|
out << "action_bar_" << i << "_type=" << static_cast<int>(actionBar[i].type) << "\n";
|
||||||
|
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
|
||||||
|
}
|
||||||
|
LOG_INFO("Character config saved to ", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameHandler::loadCharacterConfig() {
|
||||||
|
const Character* ch = getActiveCharacter();
|
||||||
|
if (!ch || ch->name.empty()) return;
|
||||||
|
|
||||||
|
std::string path = getCharacterConfigDir() + "/" + ch->name + ".cfg";
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in.is_open()) return;
|
||||||
|
|
||||||
|
uint64_t savedGuid = 0;
|
||||||
|
std::array<int, ACTION_BAR_SLOTS> types{};
|
||||||
|
std::array<uint32_t, ACTION_BAR_SLOTS> ids{};
|
||||||
|
bool hasSlots = false;
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
std::string val = line.substr(eq + 1);
|
||||||
|
|
||||||
|
if (key == "character_guid") {
|
||||||
|
try { savedGuid = std::stoull(val); } catch (...) {}
|
||||||
|
} else if (key.rfind("action_bar_", 0) == 0) {
|
||||||
|
// Parse action_bar_N_type or action_bar_N_id
|
||||||
|
size_t firstUnderscore = 11; // length of "action_bar_"
|
||||||
|
size_t secondUnderscore = key.find('_', firstUnderscore);
|
||||||
|
if (secondUnderscore == std::string::npos) continue;
|
||||||
|
int slot = -1;
|
||||||
|
try { slot = std::stoi(key.substr(firstUnderscore, secondUnderscore - firstUnderscore)); } catch (...) { continue; }
|
||||||
|
if (slot < 0 || slot >= ACTION_BAR_SLOTS) continue;
|
||||||
|
std::string suffix = key.substr(secondUnderscore + 1);
|
||||||
|
try {
|
||||||
|
if (suffix == "type") {
|
||||||
|
types[slot] = std::stoi(val);
|
||||||
|
hasSlots = true;
|
||||||
|
} else if (suffix == "id") {
|
||||||
|
ids[slot] = static_cast<uint32_t>(std::stoul(val));
|
||||||
|
hasSlots = true;
|
||||||
|
}
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate guid matches current character
|
||||||
|
if (savedGuid != 0 && savedGuid != playerGuid) {
|
||||||
|
LOG_WARNING("Character config guid mismatch for ", ch->name, ", using defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSlots) {
|
||||||
|
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
||||||
|
actionBar[i].type = static_cast<ActionBarSlot::Type>(types[i]);
|
||||||
|
actionBar[i].id = ids[i];
|
||||||
|
}
|
||||||
|
LOG_INFO("Character config loaded from ", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace game
|
} // namespace game
|
||||||
} // namespace wowee
|
} // namespace wowee
|
||||||
|
|
|
||||||
|
|
@ -2530,12 +2530,12 @@ network::Packet ListInventoryPacket::build(uint64_t npcGuid) {
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) {
|
network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
|
||||||
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_BUY_ITEM));
|
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_BUY_ITEM));
|
||||||
packet.writeUInt64(vendorGuid);
|
packet.writeUInt64(vendorGuid);
|
||||||
packet.writeUInt32(itemId);
|
packet.writeUInt32(itemId);
|
||||||
packet.writeUInt32(slot);
|
packet.writeUInt32(slot);
|
||||||
packet.writeUInt8(count);
|
packet.writeUInt32(count);
|
||||||
packet.writeUInt8(0); // bag slot (0 = find any available bag slot)
|
packet.writeUInt8(0); // bag slot (0 = find any available bag slot)
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -577,18 +577,23 @@ void CameraController::update(float deltaTime) {
|
||||||
return base;
|
return base;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sample center + footprint to avoid slipping through narrow floor pieces.
|
// Sample center + movement-aligned offsets to avoid slipping through narrow floor pieces.
|
||||||
|
// Use 3 samples instead of 5 — center plus two movement-direction probes.
|
||||||
std::optional<float> groundH;
|
std::optional<float> groundH;
|
||||||
constexpr float FOOTPRINT = 0.4f; // Larger footprint for better floor detection
|
groundH = sampleGround(targetPos.x, targetPos.y);
|
||||||
const glm::vec2 offsets[] = {
|
{
|
||||||
{0.0f, 0.0f},
|
constexpr float FOOTPRINT = 0.4f;
|
||||||
{FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f},
|
glm::vec2 moveXY(targetPos.x - followTarget->x, targetPos.y - followTarget->y);
|
||||||
{0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT}
|
float moveLen = glm::length(moveXY);
|
||||||
};
|
if (moveLen > 0.01f) {
|
||||||
for (const auto& o : offsets) {
|
glm::vec2 moveDir2 = moveXY / moveLen;
|
||||||
auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y);
|
glm::vec2 perpDir(-moveDir2.y, moveDir2.x);
|
||||||
if (h && (!groundH || *h > *groundH)) {
|
auto h1 = sampleGround(targetPos.x + moveDir2.x * FOOTPRINT,
|
||||||
groundH = h;
|
targetPos.y + moveDir2.y * FOOTPRINT);
|
||||||
|
if (h1 && (!groundH || *h1 > *groundH)) groundH = h1;
|
||||||
|
auto h2 = sampleGround(targetPos.x + perpDir.x * FOOTPRINT,
|
||||||
|
targetPos.y + perpDir.y * FOOTPRINT);
|
||||||
|
if (h2 && (!groundH || *h2 > *groundH)) groundH = h2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <future>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <thread>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
|
|
@ -38,6 +40,8 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) {
|
||||||
|
|
||||||
assetManager = assets;
|
assetManager = assets;
|
||||||
|
|
||||||
|
numCullThreads_ = std::min(4u, std::max(1u, std::thread::hardware_concurrency() - 1));
|
||||||
|
|
||||||
// Create WMO shader with texture support
|
// Create WMO shader with texture support
|
||||||
const char* vertexSrc = R"(
|
const char* vertexSrc = R"(
|
||||||
#version 330 core
|
#version 330 core
|
||||||
|
|
@ -83,6 +87,8 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) {
|
||||||
uniform sampler2D uTexture;
|
uniform sampler2D uTexture;
|
||||||
uniform bool uHasTexture;
|
uniform bool uHasTexture;
|
||||||
uniform bool uAlphaTest;
|
uniform bool uAlphaTest;
|
||||||
|
uniform bool uUnlit;
|
||||||
|
uniform bool uIsInterior;
|
||||||
|
|
||||||
uniform vec3 uFogColor;
|
uniform vec3 uFogColor;
|
||||||
uniform float uFogStart;
|
uniform float uFogStart;
|
||||||
|
|
@ -96,32 +102,54 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) {
|
||||||
out vec4 FragColor;
|
out vec4 FragColor;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// Sample texture or use vertex color
|
||||||
|
vec4 texColor;
|
||||||
|
float alpha = 1.0;
|
||||||
|
if (uHasTexture) {
|
||||||
|
texColor = texture(uTexture, TexCoord);
|
||||||
|
// Alpha test only for cutout materials (lattice, grating, etc.)
|
||||||
|
if (uAlphaTest && texColor.a < 0.5) discard;
|
||||||
|
// Multiply vertex color (MOCV baked lighting/AO) into texture
|
||||||
|
texColor.rgb *= VertexColor.rgb;
|
||||||
|
alpha = texColor.a;
|
||||||
|
} else {
|
||||||
|
// MOCV vertex color alpha is a lighting blend factor, not transparency
|
||||||
|
texColor = vec4(VertexColor.rgb, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlit materials (windows, lamps) — emit texture color directly
|
||||||
|
if (uUnlit) {
|
||||||
|
// Apply fog only
|
||||||
|
float fogDist = length(uViewPos - FragPos);
|
||||||
|
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
|
||||||
|
vec3 result = mix(uFogColor, texColor.rgb, fogFactor);
|
||||||
|
FragColor = vec4(result, alpha);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
vec3 normal = normalize(Normal);
|
vec3 normal = normalize(Normal);
|
||||||
vec3 lightDir = normalize(uLightDir);
|
vec3 lightDir = normalize(uLightDir);
|
||||||
|
|
||||||
|
// Interior vs exterior lighting
|
||||||
|
vec3 ambient;
|
||||||
|
float dirScale;
|
||||||
|
if (uIsInterior) {
|
||||||
|
ambient = vec3(0.7, 0.7, 0.7);
|
||||||
|
dirScale = 0.3;
|
||||||
|
} else {
|
||||||
|
ambient = uAmbientColor;
|
||||||
|
dirScale = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
// Diffuse lighting
|
// Diffuse lighting
|
||||||
float diff = max(dot(normal, lightDir), 0.0);
|
float diff = max(dot(normal, lightDir), 0.0);
|
||||||
vec3 diffuse = diff * vec3(1.0);
|
vec3 diffuse = diff * vec3(1.0) * dirScale;
|
||||||
|
|
||||||
// Ambient
|
|
||||||
vec3 ambient = uAmbientColor;
|
|
||||||
|
|
||||||
// Blinn-Phong specular
|
// Blinn-Phong specular
|
||||||
vec3 viewDir = normalize(uViewPos - FragPos);
|
vec3 viewDir = normalize(uViewPos - FragPos);
|
||||||
vec3 halfDir = normalize(lightDir + viewDir);
|
vec3 halfDir = normalize(lightDir + viewDir);
|
||||||
float spec = pow(max(dot(normal, halfDir), 0.0), 32.0);
|
float spec = pow(max(dot(normal, halfDir), 0.0), 32.0);
|
||||||
vec3 specular = spec * uLightColor * uSpecularIntensity;
|
vec3 specular = spec * uLightColor * uSpecularIntensity * dirScale;
|
||||||
|
|
||||||
// Sample texture or use vertex color
|
|
||||||
vec4 texColor;
|
|
||||||
if (uHasTexture) {
|
|
||||||
texColor = texture(uTexture, TexCoord);
|
|
||||||
// Alpha test only for cutout materials (lattice, grating, etc.)
|
|
||||||
if (uAlphaTest && texColor.a < 0.5) discard;
|
|
||||||
} else {
|
|
||||||
// MOCV vertex color alpha is a lighting blend factor, not transparency
|
|
||||||
texColor = vec4(VertexColor.rgb, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shadow mapping
|
// Shadow mapping
|
||||||
float shadow = 1.0;
|
float shadow = 1.0;
|
||||||
|
|
@ -153,7 +181,7 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) {
|
||||||
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
|
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
|
||||||
result = mix(uFogColor, result, fogFactor);
|
result = mix(uFogColor, result, fogFactor);
|
||||||
|
|
||||||
FragColor = vec4(result, 1.0);
|
FragColor = vec4(result, alpha);
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
|
|
@ -289,6 +317,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
|
||||||
|
|
||||||
modelData.materialTextureIndices.push_back(texIndex);
|
modelData.materialTextureIndices.push_back(texIndex);
|
||||||
modelData.materialBlendModes.push_back(mat.blendMode);
|
modelData.materialBlendModes.push_back(mat.blendMode);
|
||||||
|
modelData.materialFlags.push_back(mat.flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create GPU resources for each group
|
// Create GPU resources for each group
|
||||||
|
|
@ -300,7 +329,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupResources resources;
|
GroupResources resources;
|
||||||
if (createGroupResources(wmoGroup, resources)) {
|
if (createGroupResources(wmoGroup, resources, wmoGroup.flags)) {
|
||||||
modelData.groups.push_back(resources);
|
modelData.groups.push_back(resources);
|
||||||
loadedGroups++;
|
loadedGroups++;
|
||||||
}
|
}
|
||||||
|
|
@ -328,16 +357,28 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool alphaTest = false;
|
bool alphaTest = false;
|
||||||
|
uint32_t blendMode = 0;
|
||||||
if (batch.materialId < modelData.materialBlendModes.size()) {
|
if (batch.materialId < modelData.materialBlendModes.size()) {
|
||||||
alphaTest = (modelData.materialBlendModes[batch.materialId] == 1);
|
blendMode = modelData.materialBlendModes[batch.materialId];
|
||||||
|
alphaTest = (blendMode == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint64_t key = (static_cast<uint64_t>(texId) << 1) | (alphaTest ? 1 : 0);
|
bool unlit = false;
|
||||||
|
if (batch.materialId < modelData.materialFlags.size()) {
|
||||||
|
unlit = (modelData.materialFlags[batch.materialId] & 0x01) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge key: texture ID + alphaTest + unlit (unlit batches must not merge with lit)
|
||||||
|
uint64_t key = (static_cast<uint64_t>(texId) << 2)
|
||||||
|
| (alphaTest ? 1ULL : 0ULL)
|
||||||
|
| (unlit ? 2ULL : 0ULL);
|
||||||
auto& mb = batchMap[key];
|
auto& mb = batchMap[key];
|
||||||
if (mb.counts.empty()) {
|
if (mb.counts.empty()) {
|
||||||
mb.texId = texId;
|
mb.texId = texId;
|
||||||
mb.hasTexture = hasTexture;
|
mb.hasTexture = hasTexture;
|
||||||
mb.alphaTest = alphaTest;
|
mb.alphaTest = alphaTest;
|
||||||
|
mb.unlit = unlit;
|
||||||
|
mb.blendMode = blendMode;
|
||||||
}
|
}
|
||||||
mb.counts.push_back(static_cast<GLsizei>(batch.indexCount));
|
mb.counts.push_back(static_cast<GLsizei>(batch.indexCount));
|
||||||
mb.offsets.push_back(reinterpret_cast<const void*>(batch.startIndex * sizeof(uint16_t)));
|
mb.offsets.push_back(reinterpret_cast<const void*>(batch.startIndex * sizeof(uint16_t)));
|
||||||
|
|
@ -746,6 +787,10 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
|
||||||
glActiveTexture(GL_TEXTURE0);
|
glActiveTexture(GL_TEXTURE0);
|
||||||
shader->setUniform("uTexture", 0);
|
shader->setUniform("uTexture", 0);
|
||||||
|
|
||||||
|
// Initialize new uniforms to defaults
|
||||||
|
shader->setUniform("uUnlit", false);
|
||||||
|
shader->setUniform("uIsInterior", false);
|
||||||
|
|
||||||
// Enable wireframe if requested
|
// Enable wireframe if requested
|
||||||
if (wireframeMode) {
|
if (wireframeMode) {
|
||||||
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
|
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
|
||||||
|
|
@ -778,82 +823,139 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render all instances with instance-level culling
|
// ── Phase 1: Parallel visibility culling ──────────────────────────
|
||||||
for (const auto& instance : instances) {
|
// Build list of instances that pass the coarse instance-level frustum test.
|
||||||
// NOTE: Disabled hard instance-distance culling for WMOs.
|
std::vector<size_t> visibleInstances;
|
||||||
// Large city WMOs can have instance origins far from local camera position,
|
visibleInstances.reserve(instances.size());
|
||||||
// causing whole city sections to disappear unexpectedly.
|
for (size_t i = 0; i < instances.size(); ++i) {
|
||||||
|
const auto& instance = instances[i];
|
||||||
auto modelIt = loadedModels.find(instance.modelId);
|
if (loadedModels.find(instance.modelId) == loadedModels.end())
|
||||||
if (modelIt == loadedModels.end()) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
if (frustumCulling) {
|
if (frustumCulling) {
|
||||||
glm::vec3 instMin = instance.worldBoundsMin - glm::vec3(0.5f);
|
glm::vec3 instMin = instance.worldBoundsMin - glm::vec3(0.5f);
|
||||||
glm::vec3 instMax = instance.worldBoundsMax + glm::vec3(0.5f);
|
glm::vec3 instMax = instance.worldBoundsMax + glm::vec3(0.5f);
|
||||||
if (!frustum.intersectsAABB(instMin, instMax)) {
|
if (!frustum.intersectsAABB(instMin, instMax))
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
visibleInstances.push_back(i);
|
||||||
|
}
|
||||||
|
|
||||||
const ModelData& model = modelIt->second;
|
// Per-instance cull lambda — produces an InstanceDrawList for one instance.
|
||||||
|
// Reads only const data; each invocation writes to its own output.
|
||||||
|
glm::vec3 camPos = camera.getPosition();
|
||||||
|
bool doPortalCull = portalCulling;
|
||||||
|
bool doOcclusionCull = occlusionCulling;
|
||||||
|
bool doFrustumCull = frustumCulling;
|
||||||
|
|
||||||
// Run occlusion queries for this instance (pre-pass)
|
auto cullInstance = [&](size_t instIdx) -> InstanceDrawList {
|
||||||
if (occlusionCulling && occlusionShader && bboxVao != 0) {
|
const auto& instance = instances[instIdx];
|
||||||
runOcclusionQueries(instance, model, view, projection);
|
const ModelData& model = loadedModels.find(instance.modelId)->second;
|
||||||
// Re-bind main shader after occlusion pass
|
|
||||||
shader->use();
|
|
||||||
}
|
|
||||||
|
|
||||||
shader->setUniform("uModel", instance.modelMatrix);
|
InstanceDrawList result;
|
||||||
|
result.instanceIndex = instIdx;
|
||||||
|
|
||||||
// Portal-based visibility culling
|
// Portal-based visibility
|
||||||
std::unordered_set<uint32_t> portalVisibleGroups;
|
std::unordered_set<uint32_t> portalVisibleGroups;
|
||||||
bool usePortalCulling = portalCulling && !model.portals.empty() && !model.portalRefs.empty();
|
bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty();
|
||||||
|
|
||||||
if (usePortalCulling) {
|
if (usePortalCulling) {
|
||||||
// Transform camera position to model's local space
|
glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f);
|
||||||
glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camera.getPosition(), 1.0f);
|
getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum,
|
||||||
glm::vec3 cameraLocalPos(localCamPos);
|
instance.modelMatrix, portalVisibleGroups);
|
||||||
|
|
||||||
getVisibleGroupsViaPortals(model, cameraLocalPos, frustum, instance.modelMatrix, portalVisibleGroups);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render all groups using cached world-space bounds
|
|
||||||
glm::vec3 camPos = camera.getPosition();
|
|
||||||
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
|
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
|
||||||
// Portal culling check
|
// Portal culling
|
||||||
if (usePortalCulling && portalVisibleGroups.find(static_cast<uint32_t>(gi)) == portalVisibleGroups.end()) {
|
if (usePortalCulling &&
|
||||||
lastPortalCulledGroups++;
|
portalVisibleGroups.find(static_cast<uint32_t>(gi)) == portalVisibleGroups.end()) {
|
||||||
|
result.portalCulled++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Occlusion culling check first (uses previous frame results)
|
// Occlusion culling (reads previous-frame results, read-only map)
|
||||||
if (occlusionCulling && isGroupOccluded(instance.id, static_cast<uint32_t>(gi))) {
|
if (doOcclusionCull && isGroupOccluded(instance.id, static_cast<uint32_t>(gi))) {
|
||||||
lastOcclusionCulledGroups++;
|
result.occlusionCulled++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gi < instance.worldGroupBounds.size()) {
|
if (gi < instance.worldGroupBounds.size()) {
|
||||||
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
|
||||||
// Hard distance cutoff - skip groups entirely if closest point is too far
|
// Hard distance cutoff
|
||||||
glm::vec3 closestPoint = glm::clamp(camPos, gMin, gMax);
|
glm::vec3 closestPoint = glm::clamp(camPos, gMin, gMax);
|
||||||
float distSq = glm::dot(closestPoint - camPos, closestPoint - camPos);
|
float distSq = glm::dot(closestPoint - camPos, closestPoint - camPos);
|
||||||
if (distSq > 25600.0f) { // Beyond 160 units - hard skip
|
if (distSq > 25600.0f) {
|
||||||
lastDistanceCulledGroups++;
|
result.distanceCulled++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frustum culling
|
// Frustum culling
|
||||||
if (frustumCulling && !frustum.intersectsAABB(gMin, gMax)) {
|
if (doFrustumCull && !frustum.intersectsAABB(gMin, gMax))
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGroup(model.groups[gi], model, instance.modelMatrix, view, projection);
|
result.visibleGroups.push_back(static_cast<uint32_t>(gi));
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dispatch culling — parallel when enough instances, sequential otherwise.
|
||||||
|
std::vector<InstanceDrawList> drawLists;
|
||||||
|
drawLists.reserve(visibleInstances.size());
|
||||||
|
|
||||||
|
if (visibleInstances.size() >= 4 && numCullThreads_ > 1) {
|
||||||
|
const size_t numThreads = std::min(static_cast<size_t>(numCullThreads_),
|
||||||
|
visibleInstances.size());
|
||||||
|
const size_t chunkSize = visibleInstances.size() / numThreads;
|
||||||
|
const size_t remainder = visibleInstances.size() % numThreads;
|
||||||
|
|
||||||
|
// Each future returns a vector of InstanceDrawList for its chunk.
|
||||||
|
std::vector<std::future<std::vector<InstanceDrawList>>> futures;
|
||||||
|
futures.reserve(numThreads);
|
||||||
|
|
||||||
|
size_t start = 0;
|
||||||
|
for (size_t t = 0; t < numThreads; ++t) {
|
||||||
|
size_t end = start + chunkSize + (t < remainder ? 1 : 0);
|
||||||
|
futures.push_back(std::async(std::launch::async,
|
||||||
|
[&, start, end]() {
|
||||||
|
std::vector<InstanceDrawList> chunk;
|
||||||
|
chunk.reserve(end - start);
|
||||||
|
for (size_t j = start; j < end; ++j)
|
||||||
|
chunk.push_back(cullInstance(visibleInstances[j]));
|
||||||
|
return chunk;
|
||||||
|
}));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& f : futures) {
|
||||||
|
auto chunk = f.get();
|
||||||
|
for (auto& dl : chunk)
|
||||||
|
drawLists.push_back(std::move(dl));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (size_t idx : visibleInstances)
|
||||||
|
drawLists.push_back(cullInstance(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Sequential GL draw ────────────────────────────────
|
||||||
|
for (const auto& dl : drawLists) {
|
||||||
|
const auto& instance = instances[dl.instanceIndex];
|
||||||
|
const ModelData& model = loadedModels.find(instance.modelId)->second;
|
||||||
|
|
||||||
|
// Occlusion query pre-pass (GL calls — must be main thread)
|
||||||
|
if (occlusionCulling && occlusionShader && bboxVao != 0) {
|
||||||
|
runOcclusionQueries(instance, model, view, projection);
|
||||||
|
shader->use();
|
||||||
|
}
|
||||||
|
|
||||||
|
shader->setUniform("uModel", instance.modelMatrix);
|
||||||
|
|
||||||
|
for (uint32_t gi : dl.visibleGroups)
|
||||||
|
renderGroup(model.groups[gi], model, instance.modelMatrix, view, projection);
|
||||||
|
|
||||||
|
lastPortalCulledGroups += dl.portalCulled;
|
||||||
|
lastDistanceCulledGroups += dl.distanceCulled;
|
||||||
|
lastOcclusionCulledGroups += dl.occlusionCulled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore polygon mode
|
// Restore polygon mode
|
||||||
|
|
@ -898,11 +1000,13 @@ uint32_t WMORenderer::getTotalTriangleCount() const {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources) {
|
bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources, uint32_t groupFlags) {
|
||||||
if (group.vertices.empty() || group.indices.empty()) {
|
if (group.vertices.empty() || group.indices.empty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resources.groupFlags = groupFlags;
|
||||||
|
|
||||||
resources.vertexCount = group.vertices.size();
|
resources.vertexCount = group.vertices.size();
|
||||||
resources.indexCount = group.indices.size();
|
resources.indexCount = group.indices.size();
|
||||||
resources.boundingBoxMin = group.boundingBoxMin;
|
resources.boundingBoxMin = group.boundingBoxMin;
|
||||||
|
|
@ -1012,11 +1116,16 @@ void WMORenderer::renderGroup(const GroupResources& group, [[maybe_unused]] cons
|
||||||
[[maybe_unused]] const glm::mat4& projection) {
|
[[maybe_unused]] const glm::mat4& projection) {
|
||||||
glBindVertexArray(group.vao);
|
glBindVertexArray(group.vao);
|
||||||
|
|
||||||
|
// Set interior flag once per group (0x2000 = interior)
|
||||||
|
bool isInterior = (group.groupFlags & 0x2000) != 0;
|
||||||
|
shader->setUniform("uIsInterior", isInterior);
|
||||||
|
|
||||||
// Use pre-computed merged batches (built at load time)
|
// Use pre-computed merged batches (built at load time)
|
||||||
// Track bound state to avoid redundant GL calls
|
// Track bound state to avoid redundant GL calls
|
||||||
static GLuint lastBoundTex = 0;
|
static GLuint lastBoundTex = 0;
|
||||||
static bool lastHasTexture = false;
|
static bool lastHasTexture = false;
|
||||||
static bool lastAlphaTest = false;
|
static bool lastAlphaTest = false;
|
||||||
|
static bool lastUnlit = false;
|
||||||
|
|
||||||
for (const auto& mb : group.mergedBatches) {
|
for (const auto& mb : group.mergedBatches) {
|
||||||
if (mb.texId != lastBoundTex) {
|
if (mb.texId != lastBoundTex) {
|
||||||
|
|
@ -1031,10 +1140,25 @@ void WMORenderer::renderGroup(const GroupResources& group, [[maybe_unused]] cons
|
||||||
shader->setUniform("uAlphaTest", mb.alphaTest);
|
shader->setUniform("uAlphaTest", mb.alphaTest);
|
||||||
lastAlphaTest = mb.alphaTest;
|
lastAlphaTest = mb.alphaTest;
|
||||||
}
|
}
|
||||||
|
if (mb.unlit != lastUnlit) {
|
||||||
|
shader->setUniform("uUnlit", mb.unlit);
|
||||||
|
lastUnlit = mb.unlit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable alpha blending for translucent materials (blendMode >= 2)
|
||||||
|
bool needsBlend = (mb.blendMode >= 2);
|
||||||
|
if (needsBlend) {
|
||||||
|
glEnable(GL_BLEND);
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
glMultiDrawElements(GL_TRIANGLES, mb.counts.data(), GL_UNSIGNED_SHORT,
|
glMultiDrawElements(GL_TRIANGLES, mb.counts.data(), GL_UNSIGNED_SHORT,
|
||||||
mb.offsets.data(), static_cast<GLsizei>(mb.counts.size()));
|
mb.offsets.data(), static_cast<GLsizei>(mb.counts.size()));
|
||||||
lastDrawCalls++;
|
lastDrawCalls++;
|
||||||
|
|
||||||
|
if (needsBlend) {
|
||||||
|
glDisable(GL_BLEND);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
glBindVertexArray(0);
|
glBindVertexArray(0);
|
||||||
|
|
@ -1407,12 +1531,6 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
|
||||||
glm::vec3 worldOrigin(glX, glY, glZ + 500.0f);
|
glm::vec3 worldOrigin(glX, glY, glZ + 500.0f);
|
||||||
glm::vec3 worldDir(0.0f, 0.0f, -1.0f);
|
glm::vec3 worldDir(0.0f, 0.0f, -1.0f);
|
||||||
|
|
||||||
// Debug: log when no instances
|
|
||||||
static int debugCounter = 0;
|
|
||||||
if (instances.empty() && (debugCounter++ % 300 == 0)) {
|
|
||||||
core::Logger::getInstance().warning("WMO getFloorHeight: no instances loaded!");
|
|
||||||
}
|
|
||||||
|
|
||||||
glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f);
|
glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f);
|
||||||
glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f);
|
glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f);
|
||||||
gatherCandidates(queryMin, queryMax, candidateScratch);
|
gatherCandidates(queryMin, queryMax, candidateScratch);
|
||||||
|
|
@ -1436,23 +1554,41 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
|
||||||
|
|
||||||
const ModelData& model = it->second;
|
const ModelData& model = it->second;
|
||||||
|
|
||||||
|
// World-space pre-pass: check which groups' world XY bounds contain
|
||||||
|
// the query point. For a vertical ray this eliminates most groups
|
||||||
|
// before any local-space math.
|
||||||
|
bool anyGroupOverlaps = false;
|
||||||
|
for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (glX >= gMin.x && glX <= gMax.x &&
|
||||||
|
glY >= gMin.y && glY <= gMax.y &&
|
||||||
|
glZ - 4.0f <= gMax.z) {
|
||||||
|
anyGroupOverlaps = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyGroupOverlaps) continue;
|
||||||
|
|
||||||
// Use cached inverse matrix
|
// Use cached inverse matrix
|
||||||
glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f));
|
glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f));
|
||||||
glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f)));
|
glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f)));
|
||||||
|
|
||||||
int groupsChecked = 0;
|
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
|
||||||
int groupsSkipped = 0;
|
// World-space group cull — vertical ray at (glX, glY)
|
||||||
int trianglesHit = 0;
|
if (gi < instance.worldGroupBounds.size()) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (glX < gMin.x || glX > gMax.x ||
|
||||||
|
glY < gMin.y || glY > gMax.y ||
|
||||||
|
glZ - 4.0f > gMax.z) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const auto& group : model.groups) {
|
const auto& group = model.groups[gi];
|
||||||
// Quick bounding box check
|
|
||||||
if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) {
|
if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) {
|
||||||
groupsSkipped++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groupsChecked++;
|
|
||||||
|
|
||||||
// Raycast against triangles
|
|
||||||
const auto& verts = group.collisionVertices;
|
const auto& verts = group.collisionVertices;
|
||||||
const auto& indices = group.collisionIndices;
|
const auto& indices = group.collisionIndices;
|
||||||
|
|
||||||
|
|
@ -1461,22 +1597,15 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
|
||||||
const glm::vec3& v1 = verts[indices[i + 1]];
|
const glm::vec3& v1 = verts[indices[i + 1]];
|
||||||
const glm::vec3& v2 = verts[indices[i + 2]];
|
const glm::vec3& v2 = verts[indices[i + 2]];
|
||||||
|
|
||||||
// Try both winding orders (two-sided collision)
|
|
||||||
float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2);
|
float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2);
|
||||||
if (t <= 0.0f) {
|
if (t <= 0.0f) {
|
||||||
// Try reverse winding
|
|
||||||
t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1);
|
t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t > 0.0f) {
|
if (t > 0.0f) {
|
||||||
trianglesHit++;
|
|
||||||
// Hit point in local space -> world space
|
|
||||||
glm::vec3 hitLocal = localOrigin + localDir * t;
|
glm::vec3 hitLocal = localOrigin + localDir * t;
|
||||||
glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f));
|
glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f));
|
||||||
|
|
||||||
// Only use floors below or near the query point.
|
|
||||||
// Callers already elevate glZ by +5..+6; keep buffer small
|
|
||||||
// to avoid selecting ceilings above the player.
|
|
||||||
if (hitWorld.z <= glZ + 0.5f) {
|
if (hitWorld.z <= glZ + 0.5f) {
|
||||||
if (!bestFloor || hitWorld.z > *bestFloor) {
|
if (!bestFloor || hitWorld.z > *bestFloor) {
|
||||||
bestFloor = hitWorld.z;
|
bestFloor = hitWorld.z;
|
||||||
|
|
@ -1485,14 +1614,6 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logging (every ~5 seconds at 60fps)
|
|
||||||
static int logCounter = 0;
|
|
||||||
if ((logCounter++ % 300 == 0) && (groupsChecked > 0 || groupsSkipped > 0)) {
|
|
||||||
core::Logger::getInstance().debug("Floor check: ", groupsChecked, " groups checked, ",
|
|
||||||
groupsSkipped, " skipped, ", trianglesHit, " hits, best=",
|
|
||||||
bestFloor ? std::to_string(*bestFloor) : "none");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result in persistent grid.
|
// Cache the result in persistent grid.
|
||||||
|
|
@ -1519,11 +1640,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
|
||||||
const float PLAYER_HEIGHT = 2.0f; // Player height for wall checks
|
const float PLAYER_HEIGHT = 2.0f; // Player height for wall checks
|
||||||
const float MAX_STEP_HEIGHT = 1.0f; // Allow stepping up stairs
|
const float MAX_STEP_HEIGHT = 1.0f; // Allow stepping up stairs
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
static int wallDebugCounter = 0;
|
|
||||||
int groupsChecked = 0;
|
|
||||||
int wallsHit = 0;
|
|
||||||
|
|
||||||
glm::vec3 queryMin = glm::min(from, to) - glm::vec3(8.0f, 8.0f, 5.0f);
|
glm::vec3 queryMin = glm::min(from, to) - glm::vec3(8.0f, 8.0f, 5.0f);
|
||||||
glm::vec3 queryMax = glm::max(from, to) + glm::vec3(8.0f, 8.0f, 5.0f);
|
glm::vec3 queryMax = glm::max(from, to) + glm::vec3(8.0f, 8.0f, 5.0f);
|
||||||
gatherCandidates(queryMin, queryMax, candidateScratch);
|
gatherCandidates(queryMin, queryMax, candidateScratch);
|
||||||
|
|
@ -1548,19 +1664,43 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
|
||||||
|
|
||||||
const ModelData& model = it->second;
|
const ModelData& model = it->second;
|
||||||
|
|
||||||
|
// World-space pre-pass: skip instances where no groups are near the movement
|
||||||
|
const float wallMargin = PLAYER_RADIUS + 2.0f;
|
||||||
|
bool anyGroupNear = false;
|
||||||
|
for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (to.x >= gMin.x - wallMargin && to.x <= gMax.x + wallMargin &&
|
||||||
|
to.y >= gMin.y - wallMargin && to.y <= gMax.y + wallMargin &&
|
||||||
|
to.z + PLAYER_HEIGHT >= gMin.z && to.z <= gMax.z + wallMargin) {
|
||||||
|
anyGroupNear = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyGroupNear) continue;
|
||||||
|
|
||||||
// Transform positions into local space using cached inverse
|
// Transform positions into local space using cached inverse
|
||||||
glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f));
|
glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f));
|
||||||
glm::vec3 localTo = glm::vec3(instance.invModelMatrix * glm::vec4(to, 1.0f));
|
glm::vec3 localTo = glm::vec3(instance.invModelMatrix * glm::vec4(to, 1.0f));
|
||||||
float localFeetZ = localTo.z;
|
float localFeetZ = localTo.z;
|
||||||
for (const auto& group : model.groups) {
|
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
|
||||||
// Quick bounding box check
|
// World-space group cull
|
||||||
|
if (gi < instance.worldGroupBounds.size()) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (to.x < gMin.x - wallMargin || to.x > gMax.x + wallMargin ||
|
||||||
|
to.y < gMin.y - wallMargin || to.y > gMax.y + wallMargin ||
|
||||||
|
to.z > gMax.z + PLAYER_HEIGHT || to.z + PLAYER_HEIGHT < gMin.z) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& group = model.groups[gi];
|
||||||
|
// Local-space AABB check
|
||||||
float margin = PLAYER_RADIUS + 2.0f;
|
float margin = PLAYER_RADIUS + 2.0f;
|
||||||
if (localTo.x < group.boundingBoxMin.x - margin || localTo.x > group.boundingBoxMax.x + margin ||
|
if (localTo.x < group.boundingBoxMin.x - margin || localTo.x > group.boundingBoxMax.x + margin ||
|
||||||
localTo.y < group.boundingBoxMin.y - margin || localTo.y > group.boundingBoxMax.y + margin ||
|
localTo.y < group.boundingBoxMin.y - margin || localTo.y > group.boundingBoxMax.y + margin ||
|
||||||
localTo.z < group.boundingBoxMin.z - margin || localTo.z > group.boundingBoxMax.z + margin) {
|
localTo.z < group.boundingBoxMin.z - margin || localTo.z > group.boundingBoxMax.z + margin) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groupsChecked++;
|
|
||||||
|
|
||||||
const auto& verts = group.collisionVertices;
|
const auto& verts = group.collisionVertices;
|
||||||
const auto& indices = group.collisionIndices;
|
const auto& indices = group.collisionIndices;
|
||||||
|
|
@ -1631,7 +1771,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
|
||||||
glm::vec3 delta = localTo - closest;
|
glm::vec3 delta = localTo - closest;
|
||||||
float horizDist = glm::length(glm::vec2(delta.x, delta.y));
|
float horizDist = glm::length(glm::vec2(delta.x, delta.y));
|
||||||
if (horizDist <= PLAYER_RADIUS) {
|
if (horizDist <= PLAYER_RADIUS) {
|
||||||
wallsHit++;
|
|
||||||
float pushDist = PLAYER_RADIUS - horizDist + 0.02f;
|
float pushDist = PLAYER_RADIUS - horizDist + 0.02f;
|
||||||
glm::vec2 pushDir2;
|
glm::vec2 pushDir2;
|
||||||
if (horizDist > 1e-4f) {
|
if (horizDist > 1e-4f) {
|
||||||
|
|
@ -1643,10 +1782,8 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
|
||||||
}
|
}
|
||||||
glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f);
|
glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f);
|
||||||
|
|
||||||
// Transform push vector back to world space
|
|
||||||
glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f));
|
glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f));
|
||||||
|
|
||||||
// Only horizontal push
|
|
||||||
adjustedPos.x += pushWorld.x;
|
adjustedPos.x += pushWorld.x;
|
||||||
adjustedPos.y += pushWorld.y;
|
adjustedPos.y += pushWorld.y;
|
||||||
blocked = true;
|
blocked = true;
|
||||||
|
|
@ -1655,12 +1792,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logging every ~5 seconds
|
|
||||||
if ((wallDebugCounter++ % 300 == 0) && !instances.empty()) {
|
|
||||||
core::Logger::getInstance().debug("Wall collision: ", instances.size(), " instances, ",
|
|
||||||
groupsChecked, " groups checked, ", wallsHit, " walls hit, blocked=", blocked);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1687,9 +1818,21 @@ bool WMORenderer::isInsideWMO(float glX, float glY, float glZ, uint32_t* outMode
|
||||||
if (it == loadedModels.end()) continue;
|
if (it == loadedModels.end()) continue;
|
||||||
|
|
||||||
const ModelData& model = it->second;
|
const ModelData& model = it->second;
|
||||||
glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f));
|
|
||||||
|
|
||||||
// Check if inside any group's bounding box
|
// World-space pre-check: skip instance if no group's world bounds contain point
|
||||||
|
bool anyGroupContains = false;
|
||||||
|
for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (glX >= gMin.x && glX <= gMax.x &&
|
||||||
|
glY >= gMin.y && glY <= gMax.y &&
|
||||||
|
glZ >= gMin.z && glZ <= gMax.z) {
|
||||||
|
anyGroupContains = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyGroupContains) continue;
|
||||||
|
|
||||||
|
glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f));
|
||||||
for (const auto& group : model.groups) {
|
for (const auto& group : model.groups) {
|
||||||
if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x &&
|
if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x &&
|
||||||
localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y &&
|
localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y &&
|
||||||
|
|
@ -1746,8 +1889,17 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3
|
||||||
glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f));
|
glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f));
|
||||||
glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f)));
|
glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f)));
|
||||||
|
|
||||||
for (const auto& group : model.groups) {
|
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
|
||||||
// Broad-phase cull with local AABB first.
|
// World-space group cull — skip groups whose world AABB doesn't intersect the ray
|
||||||
|
if (gi < instance.worldGroupBounds.size()) {
|
||||||
|
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
|
||||||
|
if (!rayIntersectsAABB(origin, direction, gMin, gMax)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& group = model.groups[gi];
|
||||||
|
// Local-space AABB cull
|
||||||
if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) {
|
if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
#include "ui/character_screen.hpp"
|
#include "ui/character_screen.hpp"
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
|
@ -9,12 +12,67 @@ CharacterScreen::CharacterScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void CharacterScreen::render(game::GameHandler& gameHandler) {
|
void CharacterScreen::render(game::GameHandler& gameHandler) {
|
||||||
ImGui::SetNextWindowSize(ImVec2(800, 600), ImGuiCond_FirstUseEver);
|
// 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));
|
||||||
|
|
||||||
ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse);
|
ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse);
|
||||||
|
|
||||||
ImGui::Text("Select a Character");
|
// Get character list
|
||||||
ImGui::Separator();
|
const auto& characters = gameHandler.getCharacters();
|
||||||
ImGui::Spacing();
|
|
||||||
|
// Request character list if not available
|
||||||
|
if (characters.empty() && gameHandler.getState() == game::WorldState::READY) {
|
||||||
|
ImGui::Text("Loading characters...");
|
||||||
|
gameHandler.requestCharacterList();
|
||||||
|
ImGui::End();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (characters.empty()) {
|
||||||
|
ImGui::Text("No characters available.");
|
||||||
|
// Bottom buttons even when empty
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); }
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Refresh", ImVec2(120, 36))) {
|
||||||
|
if (gameHandler.getState() == game::WorldState::READY ||
|
||||||
|
gameHandler.getState() == game::WorldState::CHAR_LIST_RECEIVED) {
|
||||||
|
gameHandler.requestCharacterList();
|
||||||
|
setStatus("Refreshing character list...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Create Character", ImVec2(160, 36))) { if (onCreateCharacter) onCreateCharacter(); }
|
||||||
|
ImGui::End();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore last-selected character (once per screen visit)
|
||||||
|
if (!restoredLastCharacter) {
|
||||||
|
uint64_t lastGuid = loadLastCharacter();
|
||||||
|
if (lastGuid != 0) {
|
||||||
|
for (size_t i = 0; i < characters.size(); ++i) {
|
||||||
|
if (characters[i].guid == lastGuid) {
|
||||||
|
selectedCharacterIndex = static_cast<int>(i);
|
||||||
|
selectedCharacterGuid = lastGuid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to first character if nothing matched
|
||||||
|
if (selectedCharacterIndex < 0) {
|
||||||
|
selectedCharacterIndex = 0;
|
||||||
|
selectedCharacterGuid = characters[0].guid;
|
||||||
|
}
|
||||||
|
restoredLastCharacter = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Status message
|
// Status message
|
||||||
if (!statusMessage.empty()) {
|
if (!statusMessage.empty()) {
|
||||||
|
|
@ -24,200 +82,164 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get character list
|
// ── Two-column layout: character list (left) | details (right) ──
|
||||||
const auto& characters = gameHandler.getCharacters();
|
float availW = ImGui::GetContentRegionAvail().x;
|
||||||
|
float detailPanelW = 260.0f;
|
||||||
|
float listW = availW - detailPanelW - ImGui::GetStyle().ItemSpacing.x;
|
||||||
|
if (listW < 300.0f) { listW = availW; detailPanelW = 0.0f; }
|
||||||
|
|
||||||
// Request character list if not available
|
float listH = ImGui::GetContentRegionAvail().y - 50.0f; // reserve bottom row for buttons
|
||||||
if (characters.empty() && gameHandler.getState() == game::WorldState::READY) {
|
|
||||||
ImGui::Text("Loading characters...");
|
|
||||||
gameHandler.requestCharacterList();
|
|
||||||
} else if (characters.empty()) {
|
|
||||||
ImGui::Text("No characters available.");
|
|
||||||
} else {
|
|
||||||
// Auto-highlight the first character if none selected yet
|
|
||||||
if (selectedCharacterIndex < 0 && !characters.empty()) {
|
|
||||||
selectedCharacterIndex = 0;
|
|
||||||
selectedCharacterGuid = characters[0].guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Character table
|
// ── Left: Character list ──
|
||||||
if (ImGui::BeginTable("CharactersTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
|
ImGui::BeginChild("CharList", ImVec2(listW, listH), true);
|
||||||
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
|
ImGui::Text("Characters");
|
||||||
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 50.0f);
|
ImGui::Separator();
|
||||||
ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthFixed, 100.0f);
|
|
||||||
ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 120.0f);
|
|
||||||
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f);
|
|
||||||
ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthFixed, 80.0f);
|
|
||||||
ImGui::TableHeadersRow();
|
|
||||||
|
|
||||||
for (size_t i = 0; i < characters.size(); ++i) {
|
if (ImGui::BeginTable("CharactersTable", 5,
|
||||||
const auto& character = characters[i];
|
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
||||||
|
ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp)) {
|
||||||
|
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 2.0f);
|
||||||
|
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f);
|
||||||
|
ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f);
|
||||||
|
ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f);
|
||||||
|
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f);
|
||||||
|
ImGui::TableSetupScrollFreeze(0, 1);
|
||||||
|
ImGui::TableHeadersRow();
|
||||||
|
|
||||||
ImGui::TableNextRow();
|
for (size_t i = 0; i < characters.size(); ++i) {
|
||||||
|
const auto& character = characters[i];
|
||||||
|
ImGui::TableNextRow();
|
||||||
|
ImGui::TableSetColumnIndex(0);
|
||||||
|
|
||||||
// Name column (selectable)
|
bool isSelected = (selectedCharacterIndex == static_cast<int>(i));
|
||||||
ImGui::TableSetColumnIndex(0);
|
ImVec4 factionColor = getFactionColor(character.race);
|
||||||
bool isSelected = (selectedCharacterIndex == static_cast<int>(i));
|
ImGui::PushStyleColor(ImGuiCol_Text, factionColor);
|
||||||
|
|
||||||
// Apply faction color to character name
|
ImGui::PushID(static_cast<int>(i));
|
||||||
ImVec4 factionColor = getFactionColor(character.race);
|
if (ImGui::Selectable(character.name.c_str(), isSelected,
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, factionColor);
|
ImGuiSelectableFlags_SpanAllColumns)) {
|
||||||
|
selectedCharacterIndex = static_cast<int>(i);
|
||||||
if (ImGui::Selectable(character.name.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns)) {
|
selectedCharacterGuid = character.guid;
|
||||||
selectedCharacterIndex = static_cast<int>(i);
|
saveLastCharacter(character.guid);
|
||||||
selectedCharacterGuid = character.guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
|
|
||||||
// Level column
|
|
||||||
ImGui::TableSetColumnIndex(1);
|
|
||||||
ImGui::Text("%d", character.level);
|
|
||||||
|
|
||||||
// Race column
|
|
||||||
ImGui::TableSetColumnIndex(2);
|
|
||||||
ImGui::Text("%s", game::getRaceName(character.race));
|
|
||||||
|
|
||||||
// Class column
|
|
||||||
ImGui::TableSetColumnIndex(3);
|
|
||||||
ImGui::Text("%s", game::getClassName(character.characterClass));
|
|
||||||
|
|
||||||
// Zone column
|
|
||||||
ImGui::TableSetColumnIndex(4);
|
|
||||||
ImGui::Text("%d", character.zoneId);
|
|
||||||
|
|
||||||
// Guild column
|
|
||||||
ImGui::TableSetColumnIndex(5);
|
|
||||||
if (character.hasGuild()) {
|
|
||||||
ImGui::Text("Yes");
|
|
||||||
} else {
|
|
||||||
ImGui::TextDisabled("No");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::EndTable();
|
// Double-click to enter world
|
||||||
|
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||||
|
selectedCharacterIndex = static_cast<int>(i);
|
||||||
|
selectedCharacterGuid = character.guid;
|
||||||
|
saveLastCharacter(character.guid);
|
||||||
|
characterSelected = true;
|
||||||
|
gameHandler.selectCharacter(character.guid);
|
||||||
|
if (onCharacterSelected) onCharacterSelected(character.guid);
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(1);
|
||||||
|
ImGui::Text("%d", character.level);
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(2);
|
||||||
|
ImGui::Text("%s", game::getRaceName(character.race));
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(3);
|
||||||
|
ImGui::Text("%s", game::getClassName(character.characterClass));
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(4);
|
||||||
|
ImGui::Text("%d", character.zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndTable();
|
||||||
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
|
||||||
|
// ── Right: Details panel ──
|
||||||
|
if (detailPanelW > 0.0f &&
|
||||||
|
selectedCharacterIndex >= 0 &&
|
||||||
|
selectedCharacterIndex < static_cast<int>(characters.size())) {
|
||||||
|
|
||||||
|
const auto& character = characters[selectedCharacterIndex];
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true);
|
||||||
|
|
||||||
|
ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str());
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
ImGui::Text("Level %d", character.level);
|
||||||
|
ImGui::Text("%s", game::getRaceName(character.race));
|
||||||
|
ImGui::Text("%s", game::getClassName(character.characterClass));
|
||||||
|
ImGui::Text("%s", game::getGenderName(character.gender));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId);
|
||||||
|
|
||||||
|
if (character.hasGuild()) {
|
||||||
|
ImGui::Text("Guild ID: %d", character.guildId);
|
||||||
|
} else {
|
||||||
|
ImGui::TextDisabled("No Guild");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character.hasPet()) {
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Text("Pet Lv%d (Family %d)", character.pet.level, character.pet.family);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Selected character details
|
// Enter World button — full width
|
||||||
if (selectedCharacterIndex >= 0 && selectedCharacterIndex < static_cast<int>(characters.size())) {
|
float btnW = ImGui::GetContentRegionAvail().x;
|
||||||
const auto& character = characters[selectedCharacterIndex];
|
if (ImGui::Button("Enter World", ImVec2(btnW, 44))) {
|
||||||
|
characterSelected = true;
|
||||||
|
saveLastCharacter(character.guid);
|
||||||
|
std::stringstream ss;
|
||||||
|
ss << "Entering world with " << character.name << "...";
|
||||||
|
setStatus(ss.str());
|
||||||
|
gameHandler.selectCharacter(character.guid);
|
||||||
|
if (onCharacterSelected) onCharacterSelected(character.guid);
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Text("Character Details:");
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
|
||||||
|
|
||||||
ImGui::Columns(2, nullptr, false);
|
// Delete
|
||||||
|
if (!confirmDelete) {
|
||||||
// Left column
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
|
||||||
ImGui::Text("Name:");
|
if (ImGui::Button("Delete Character", ImVec2(btnW, 36))) {
|
||||||
ImGui::Text("Level:");
|
confirmDelete = true;
|
||||||
ImGui::Text("Race:");
|
|
||||||
ImGui::Text("Class:");
|
|
||||||
ImGui::Text("Gender:");
|
|
||||||
ImGui::Text("Location:");
|
|
||||||
ImGui::Text("Guild:");
|
|
||||||
if (character.hasPet()) {
|
|
||||||
ImGui::Text("Pet:");
|
|
||||||
}
|
}
|
||||||
|
ImGui::PopStyleColor();
|
||||||
ImGui::NextColumn();
|
} else {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.0f, 0.0f, 1.0f));
|
||||||
// Right column
|
if (ImGui::Button("Confirm Delete?", ImVec2(btnW, 36))) {
|
||||||
ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str());
|
if (onDeleteCharacter) onDeleteCharacter(character.guid);
|
||||||
ImGui::Text("%d", character.level);
|
confirmDelete = false;
|
||||||
ImGui::Text("%s", game::getRaceName(character.race));
|
selectedCharacterIndex = -1;
|
||||||
ImGui::Text("%s", game::getClassName(character.characterClass));
|
selectedCharacterGuid = 0;
|
||||||
ImGui::Text("%s", game::getGenderName(character.gender));
|
|
||||||
ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId);
|
|
||||||
if (character.hasGuild()) {
|
|
||||||
ImGui::Text("Guild ID: %d", character.guildId);
|
|
||||||
} else {
|
|
||||||
ImGui::TextDisabled("None");
|
|
||||||
}
|
}
|
||||||
if (character.hasPet()) {
|
ImGui::PopStyleColor();
|
||||||
ImGui::Text("Level %d (Family %d)", character.pet.level, character.pet.family);
|
if (ImGui::Button("Cancel", ImVec2(btnW, 30))) {
|
||||||
}
|
confirmDelete = false;
|
||||||
|
|
||||||
ImGui::Columns(1);
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Enter World button
|
|
||||||
if (ImGui::Button("Enter World", ImVec2(150, 40))) {
|
|
||||||
characterSelected = true;
|
|
||||||
std::stringstream ss;
|
|
||||||
ss << "Entering world with " << character.name << "...";
|
|
||||||
setStatus(ss.str());
|
|
||||||
|
|
||||||
// Send CMSG_PLAYER_LOGIN to server
|
|
||||||
gameHandler.selectCharacter(character.guid);
|
|
||||||
|
|
||||||
// Call callback
|
|
||||||
if (onCharacterSelected) {
|
|
||||||
onCharacterSelected(character.guid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
|
|
||||||
// Delete Character button
|
|
||||||
if (!confirmDelete) {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
|
|
||||||
if (ImGui::Button("Delete Character", ImVec2(150, 40))) {
|
|
||||||
confirmDelete = true;
|
|
||||||
}
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
} else {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.0f, 0.0f, 1.0f));
|
|
||||||
if (ImGui::Button("Confirm Delete?", ImVec2(150, 40))) {
|
|
||||||
if (onDeleteCharacter) {
|
|
||||||
onDeleteCharacter(character.guid);
|
|
||||||
}
|
|
||||||
confirmDelete = false;
|
|
||||||
selectedCharacterIndex = -1;
|
|
||||||
selectedCharacterGuid = 0;
|
|
||||||
}
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (ImGui::Button("Cancel", ImVec2(80, 40))) {
|
|
||||||
confirmDelete = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImGui::EndChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bottom button row ──
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); }
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Back/Refresh/Create buttons
|
|
||||||
if (ImGui::Button("Back", ImVec2(120, 0))) {
|
|
||||||
if (onBack) {
|
|
||||||
onBack();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Refresh", ImVec2(120, 36))) {
|
||||||
if (ImGui::Button("Refresh", ImVec2(120, 0))) {
|
|
||||||
if (gameHandler.getState() == game::WorldState::READY ||
|
if (gameHandler.getState() == game::WorldState::READY ||
|
||||||
gameHandler.getState() == game::WorldState::CHAR_LIST_RECEIVED) {
|
gameHandler.getState() == game::WorldState::CHAR_LIST_RECEIVED) {
|
||||||
gameHandler.requestCharacterList();
|
gameHandler.requestCharacterList();
|
||||||
setStatus("Refreshing character list...");
|
setStatus("Refreshing character list...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Create Character", ImVec2(160, 36))) {
|
||||||
if (ImGui::Button("Create Character", ImVec2(150, 0))) {
|
if (onCreateCharacter) onCreateCharacter();
|
||||||
if (onCreateCharacter) {
|
|
||||||
onCreateCharacter();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
|
|
@ -234,7 +256,7 @@ ImVec4 CharacterScreen::getFactionColor(game::Race race) const {
|
||||||
race == game::Race::NIGHT_ELF ||
|
race == game::Race::NIGHT_ELF ||
|
||||||
race == game::Race::GNOME ||
|
race == game::Race::GNOME ||
|
||||||
race == game::Race::DRAENEI) {
|
race == game::Race::DRAENEI) {
|
||||||
return ImVec4(0.3f, 0.5f, 1.0f, 1.0f); // Blue
|
return ImVec4(0.3f, 0.5f, 1.0f, 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horde races: red
|
// Horde races: red
|
||||||
|
|
@ -243,11 +265,35 @@ ImVec4 CharacterScreen::getFactionColor(game::Race race) const {
|
||||||
race == game::Race::TAUREN ||
|
race == game::Race::TAUREN ||
|
||||||
race == game::Race::TROLL ||
|
race == game::Race::TROLL ||
|
||||||
race == game::Race::BLOOD_ELF) {
|
race == game::Race::BLOOD_ELF) {
|
||||||
return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red
|
return ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: white
|
|
||||||
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
|
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string CharacterScreen::getConfigDir() {
|
||||||
|
#ifdef _WIN32
|
||||||
|
const char* appdata = std::getenv("APPDATA");
|
||||||
|
return appdata ? std::string(appdata) + "\\wowee" : ".";
|
||||||
|
#else
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
return home ? std::string(home) + "/.wowee" : ".";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterScreen::saveLastCharacter(uint64_t guid) {
|
||||||
|
std::string dir = getConfigDir();
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
std::ofstream f(dir + "/last_character.cfg");
|
||||||
|
if (f) f << guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t CharacterScreen::loadLastCharacter() {
|
||||||
|
std::string path = getConfigDir() + "/last_character.cfg";
|
||||||
|
std::ifstream f(path);
|
||||||
|
uint64_t guid = 0;
|
||||||
|
if (f) f >> guid;
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
}} // namespace wowee::ui
|
}} // namespace wowee::ui
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
@ -51,6 +54,7 @@ namespace {
|
||||||
namespace wowee { namespace ui {
|
namespace wowee { namespace ui {
|
||||||
|
|
||||||
GameScreen::GameScreen() {
|
GameScreen::GameScreen() {
|
||||||
|
loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameScreen::render(game::GameHandler& gameHandler) {
|
void GameScreen::render(game::GameHandler& gameHandler) {
|
||||||
|
|
@ -114,10 +118,10 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
||||||
// Talents (N key toggle handled inside)
|
// Talents (N key toggle handled inside)
|
||||||
talentScreen.render(gameHandler);
|
talentScreen.render(gameHandler);
|
||||||
|
|
||||||
// Set up inventory screen asset manager + player appearance (once)
|
// Set up inventory screen asset manager + player appearance (re-init on character switch)
|
||||||
{
|
{
|
||||||
static bool inventoryScreenInit = false;
|
uint64_t activeGuid = gameHandler.getActiveCharacterGuid();
|
||||||
if (!inventoryScreenInit) {
|
if (activeGuid != 0 && activeGuid != inventoryScreenCharGuid_) {
|
||||||
auto* am = core::Application::getInstance().getAssetManager();
|
auto* am = core::Application::getInstance().getAssetManager();
|
||||||
if (am) {
|
if (am) {
|
||||||
inventoryScreen.setAssetManager(am);
|
inventoryScreen.setAssetManager(am);
|
||||||
|
|
@ -130,7 +134,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
||||||
inventoryScreen.setPlayerAppearance(
|
inventoryScreen.setPlayerAppearance(
|
||||||
ch->race, ch->gender, skin, face,
|
ch->race, ch->gender, skin, face,
|
||||||
hairStyle, hairColor, ch->facialFeatures);
|
hairStyle, hairColor, ch->facialFeatures);
|
||||||
inventoryScreenInit = true;
|
inventoryScreenCharGuid_ = activeGuid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +401,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
|
||||||
chatWindowPos_ = ImGui::GetWindowPos();
|
chatWindowPos_ = ImGui::GetWindowPos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click anywhere in chat window → focus the input field
|
||||||
|
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && ImGui::IsMouseClicked(0)) {
|
||||||
|
refocusChatInput = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Chat history
|
// Chat history
|
||||||
const auto& chatHistory = gameHandler.getChatHistory();
|
const auto& chatHistory = gameHandler.getChatHistory();
|
||||||
|
|
||||||
|
|
@ -3533,7 +3542,7 @@ void GameScreen::renderSettingsWindow() {
|
||||||
ImGui::Text("Interface");
|
ImGui::Text("Interface");
|
||||||
ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%");
|
ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%");
|
||||||
if (ImGui::Button("Restore Interface Defaults", ImVec2(-1, 0))) {
|
if (ImGui::Button("Restore Interface Defaults", ImVec2(-1, 0))) {
|
||||||
pendingUiOpacity = 100;
|
pendingUiOpacity = 65;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
@ -3542,6 +3551,7 @@ void GameScreen::renderSettingsWindow() {
|
||||||
|
|
||||||
if (ImGui::Button("Apply", ImVec2(-1, 0))) {
|
if (ImGui::Button("Apply", ImVec2(-1, 0))) {
|
||||||
uiOpacity_ = static_cast<float>(pendingUiOpacity) / 100.0f;
|
uiOpacity_ = static_cast<float>(pendingUiOpacity) / 100.0f;
|
||||||
|
saveSettings();
|
||||||
window->setVsync(pendingVsync);
|
window->setVsync(pendingVsync);
|
||||||
window->setFullscreen(pendingFullscreen);
|
window->setFullscreen(pendingFullscreen);
|
||||||
window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]);
|
window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]);
|
||||||
|
|
@ -3742,4 +3752,57 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string GameScreen::getSettingsPath() {
|
||||||
|
std::string dir;
|
||||||
|
#ifdef _WIN32
|
||||||
|
const char* appdata = std::getenv("APPDATA");
|
||||||
|
dir = appdata ? std::string(appdata) + "\\wowee" : ".";
|
||||||
|
#else
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
dir = home ? std::string(home) + "/.wowee" : ".";
|
||||||
|
#endif
|
||||||
|
return dir + "/settings.cfg";
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScreen::saveSettings() {
|
||||||
|
std::string path = getSettingsPath();
|
||||||
|
std::filesystem::path dir = std::filesystem::path(path).parent_path();
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
|
||||||
|
std::ofstream out(path);
|
||||||
|
if (!out.is_open()) {
|
||||||
|
LOG_WARNING("Could not save settings to ", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out << "ui_opacity=" << pendingUiOpacity << "\n";
|
||||||
|
LOG_INFO("Settings saved to ", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScreen::loadSettings() {
|
||||||
|
std::string path = getSettingsPath();
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in.is_open()) return;
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
std::string val = line.substr(eq + 1);
|
||||||
|
|
||||||
|
if (key == "ui_opacity") {
|
||||||
|
try {
|
||||||
|
int v = std::stoi(val);
|
||||||
|
if (v >= 20 && v <= 100) {
|
||||||
|
pendingUiOpacity = v;
|
||||||
|
uiOpacity_ = static_cast<float>(v) / 100.0f;
|
||||||
|
}
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG_INFO("Settings loaded from ", path);
|
||||||
|
}
|
||||||
|
|
||||||
}} // namespace wowee::ui
|
}} // namespace wowee::ui
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue