diff --git a/include/core/application.hpp b/include/core/application.hpp index e32103b8..9fbdc2b2 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -2,6 +2,7 @@ #include "core/window.hpp" #include "core/input.hpp" +#include "game/character.hpp" #include #include #include @@ -76,6 +77,8 @@ private: void setupUICallbacks(); void spawnPlayerCharacter(); void spawnNpcs(); + std::string getPlayerModelPath() const; + static const char* mapIdToName(uint32_t mapId); static Application* instance; @@ -96,6 +99,14 @@ private: bool spawnSnapToGround = true; float lastFrameTime = 0.0f; float movementHeartbeatTimer = 0.0f; + game::Race spRace_ = game::Race::HUMAN; + game::Gender spGender_ = game::Gender::MALE; + game::Class spClass_ = game::Class::WARRIOR; + uint32_t spMapId_ = 0; + uint32_t spZoneId_ = 0; + glm::vec3 spSpawnCanonical_ = glm::vec3(62.0f, -9464.0f, 200.0f); + float spYawDeg_ = 0.0f; + float spPitchDeg_ = -5.0f; // Weapon model ID counter (starting high to avoid collision with character model IDs) uint32_t nextWeaponModelId_ = 1000; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 41c9be45..bc864a8c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -112,6 +112,10 @@ public: * @param characterGuid GUID of character to log in with */ void selectCharacter(uint64_t characterGuid); + void setActiveCharacterGuid(uint64_t guid) { activeCharacterGuid_ = guid; } + uint64_t getActiveCharacterGuid() const { return activeCharacterGuid_; } + const Character* getActiveCharacter() const; + const Character* getFirstCharacter() const; /** * Get current player movement info @@ -167,6 +171,9 @@ public: // Money (copper) uint64_t getMoneyCopper() const { return playerMoneyCopper_; } + // Single-player: mark character list ready for selection UI + void setSinglePlayerCharListReady(); + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -216,6 +223,7 @@ public: void setSinglePlayerMode(bool sp) { singlePlayerMode_ = sp; } bool isSinglePlayerMode() const { return singlePlayerMode_; } void simulateMotd(const std::vector& lines); + void applySinglePlayerStartData(Race race, Class cls); // NPC death callback (single-player) using NpcDeathCallback = std::function; @@ -296,6 +304,16 @@ public: */ void update(float deltaTime); + struct SinglePlayerCreateInfo { + uint32_t mapId = 0; + uint32_t zoneId = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float orientation = 0.0f; + }; + bool getSinglePlayerCreateInfo(Race race, Class cls, SinglePlayerCreateInfo& out) const; + private: /** * Handle incoming packet from world server @@ -503,6 +521,8 @@ private: bool pendingGroupInvite = false; std::string pendingInviterName; + uint64_t activeCharacterGuid_ = 0; + // ---- Phase 5: Loot ---- bool lootWindowOpen = false; LootResponseData currentLoot; diff --git a/src/core/application.cpp b/src/core/application.cpp index 02cfdec7..4c8c7c03 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -63,6 +63,71 @@ const SpawnPreset* selectSpawnPreset(const char* envValue) { return &SPAWN_PRESETS[0]; } +} // namespace + +const char* Application::mapIdToName(uint32_t mapId) { + switch (mapId) { + case 0: return "Azeroth"; + case 1: return "Kalimdor"; + case 530: return "Outland"; + case 571: return "Northrend"; + default: return "Azeroth"; + } +} + +std::string Application::getPlayerModelPath() const { + auto pick = [&](game::Race race, game::Gender gender) -> std::string { + switch (race) { + case game::Race::HUMAN: + return gender == game::Gender::FEMALE + ? "Character\\Human\\Female\\HumanFemale.m2" + : "Character\\Human\\Male\\HumanMale.m2"; + case game::Race::ORC: + return gender == game::Gender::FEMALE + ? "Character\\Orc\\Female\\OrcFemale.m2" + : "Character\\Orc\\Male\\OrcMale.m2"; + case game::Race::DWARF: + return gender == game::Gender::FEMALE + ? "Character\\Dwarf\\Female\\DwarfFemale.m2" + : "Character\\Dwarf\\Male\\DwarfMale.m2"; + case game::Race::NIGHT_ELF: + return gender == game::Gender::FEMALE + ? "Character\\NightElf\\Female\\NightElfFemale.m2" + : "Character\\NightElf\\Male\\NightElfMale.m2"; + case game::Race::UNDEAD: + return gender == game::Gender::FEMALE + ? "Character\\Scourge\\Female\\ScourgeFemale.m2" + : "Character\\Scourge\\Male\\ScourgeMale.m2"; + case game::Race::TAUREN: + return gender == game::Gender::FEMALE + ? "Character\\Tauren\\Female\\TaurenFemale.m2" + : "Character\\Tauren\\Male\\TaurenMale.m2"; + case game::Race::GNOME: + return gender == game::Gender::FEMALE + ? "Character\\Gnome\\Female\\GnomeFemale.m2" + : "Character\\Gnome\\Male\\GnomeMale.m2"; + case game::Race::TROLL: + return gender == game::Gender::FEMALE + ? "Character\\Troll\\Female\\TrollFemale.m2" + : "Character\\Troll\\Male\\TrollMale.m2"; + case game::Race::BLOOD_ELF: + return gender == game::Gender::FEMALE + ? "Character\\BloodElf\\Female\\BloodElfFemale.m2" + : "Character\\BloodElf\\Male\\BloodElfMale.m2"; + case game::Race::DRAENEI: + return gender == game::Gender::FEMALE + ? "Character\\Draenei\\Female\\DraeneiFemale.m2" + : "Character\\Draenei\\Male\\DraeneiMale.m2"; + default: + return "Character\\Human\\Male\\HumanMale.m2"; + } + }; + + return pick(spRace_, spGender_); +} + +namespace { + std::optional parseVec3Csv(const char* raw) { if (!raw || !*raw) return std::nullopt; std::stringstream ss(raw); @@ -452,7 +517,10 @@ void Application::setupUICallbacks() { uiManager->getAuthScreen().setOnSinglePlayer([this]() { LOG_INFO("Single-player mode selected, opening character creation"); singlePlayerMode = true; - gameHandler->setSinglePlayerMode(true); + if (gameHandler) { + gameHandler->setSinglePlayerMode(true); + gameHandler->setSinglePlayerCharListReady(); + } uiManager->getCharacterCreateScreen().reset(); setState(AppState::CHARACTER_CREATION); }); @@ -487,13 +555,8 @@ void Application::setupUICallbacks() { uiManager->getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) { LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec); if (singlePlayerMode) { - // Use created character's data for level/HP - for (const auto& ch : gameHandler->getCharacters()) { - if (ch.guid == characterGuid) { - uint32_t maxHp = 20 + static_cast(ch.level) * 10; - gameHandler->initLocalPlayerStats(ch.level, maxHp, maxHp); - break; - } + if (gameHandler) { + gameHandler->setActiveCharacterGuid(characterGuid); } startSinglePlayer(); } else { @@ -544,16 +607,31 @@ void Application::spawnPlayerCharacter() { auto* charRenderer = renderer->getCharacterRenderer(); auto* camera = renderer->getCamera(); bool loaded = false; + std::string m2Path = getPlayerModelPath(); + std::string modelDir; + std::string baseName; + { + size_t slash = m2Path.rfind('\\'); + if (slash != std::string::npos) { + modelDir = m2Path.substr(0, slash + 1); + baseName = m2Path.substr(slash + 1); + } else { + baseName = m2Path; + } + size_t dot = baseName.rfind('.'); + if (dot != std::string::npos) { + baseName = baseName.substr(0, dot); + } + } - // Try loading real HumanMale M2 from MPQ + // Try loading selected character model from MPQ if (assetManager && assetManager->isInitialized()) { - std::string m2Path = "Character\\Human\\Male\\HumanMale.m2"; auto m2Data = assetManager->readFile(m2Path); if (!m2Data.empty()) { auto model = pipeline::M2Loader::load(m2Data); // Load skin file for submesh/batch data - std::string skinPath = "Character\\Human\\Male\\HumanMale00.skin"; + std::string skinPath = modelDir + baseName + "00.skin"; auto skinData = assetManager->readFile(skinPath); if (!skinData.empty()) { pipeline::M2Loader::loadSkin(skinData, model); @@ -566,67 +644,77 @@ void Application::spawnPlayerCharacter() { LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'"); } - // Look up underwear textures from CharSections.dbc - std::string bodySkinPath = "Character\\Human\\Male\\HumanMaleSkin00_00.blp"; + // Look up underwear textures from CharSections.dbc (humans only for now) + bool useCharSections = (spRace_ == game::Race::HUMAN); + uint32_t targetRaceId = static_cast(spRace_); + uint32_t targetSexId = (spGender_ == game::Gender::FEMALE) ? 1u : 0u; + std::string bodySkinPath = (spGender_ == game::Gender::FEMALE) + ? "Character\\Human\\Female\\HumanFemaleSkin00_00.blp" + : "Character\\Human\\Male\\HumanMaleSkin00_00.blp"; + std::string pelvisPath = (spGender_ == game::Gender::FEMALE) + ? "Character\\Human\\Female\\HumanFemaleNakedPelvisSkin00_00.blp" + : "Character\\Human\\Male\\HumanMaleNakedPelvisSkin00_00.blp"; std::string faceLowerTexturePath; std::vector underwearPaths; - auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc"); - if (charSectionsDbc) { - LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records"); - bool foundSkin = false; - bool foundUnderwear = false; - bool foundFaceLower = false; - for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { - uint32_t raceId = charSectionsDbc->getUInt32(r, 1); - uint32_t sexId = charSectionsDbc->getUInt32(r, 2); - uint32_t baseSection = charSectionsDbc->getUInt32(r, 3); - uint32_t variationIndex = charSectionsDbc->getUInt32(r, 8); - uint32_t colorIndex = charSectionsDbc->getUInt32(r, 9); + if (useCharSections) { + auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc"); + if (charSectionsDbc) { + LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records"); + bool foundSkin = false; + bool foundUnderwear = false; + bool foundFaceLower = false; + for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { + uint32_t raceId = charSectionsDbc->getUInt32(r, 1); + uint32_t sexId = charSectionsDbc->getUInt32(r, 2); + uint32_t baseSection = charSectionsDbc->getUInt32(r, 3); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, 8); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, 9); - if (raceId != 1 || sexId != 0) continue; + if (raceId != targetRaceId || sexId != targetSexId) continue; - if (baseSection == 0 && !foundSkin && variationIndex == 0 && colorIndex == 0) { - std::string tex1 = charSectionsDbc->getString(r, 4); - if (!tex1.empty()) { - bodySkinPath = tex1; - foundSkin = true; - LOG_INFO(" DBC body skin: ", bodySkinPath); - } - } else if (baseSection == 3 && colorIndex == 0) { - (void)variationIndex; - } else if (baseSection == 1 && !foundFaceLower && variationIndex == 0 && colorIndex == 0) { - std::string tex1 = charSectionsDbc->getString(r, 4); - if (!tex1.empty()) { - faceLowerTexturePath = tex1; - foundFaceLower = true; - LOG_INFO(" DBC face texture: ", faceLowerTexturePath); - } - } else if (baseSection == 4 && !foundUnderwear && variationIndex == 0 && colorIndex == 0) { - for (int f = 4; f <= 6; f++) { - std::string tex = charSectionsDbc->getString(r, f); - if (!tex.empty()) { - underwearPaths.push_back(tex); - LOG_INFO(" DBC underwear texture: ", tex); + if (baseSection == 0 && !foundSkin && variationIndex == 0 && colorIndex == 0) { + std::string tex1 = charSectionsDbc->getString(r, 4); + if (!tex1.empty()) { + bodySkinPath = tex1; + foundSkin = true; + LOG_INFO(" DBC body skin: ", bodySkinPath); } + } else if (baseSection == 3 && colorIndex == 0) { + (void)variationIndex; + } else if (baseSection == 1 && !foundFaceLower && variationIndex == 0 && colorIndex == 0) { + std::string tex1 = charSectionsDbc->getString(r, 4); + if (!tex1.empty()) { + faceLowerTexturePath = tex1; + foundFaceLower = true; + LOG_INFO(" DBC face texture: ", faceLowerTexturePath); + } + } else if (baseSection == 4 && !foundUnderwear && variationIndex == 0 && colorIndex == 0) { + for (int f = 4; f <= 6; f++) { + std::string tex = charSectionsDbc->getString(r, f); + if (!tex.empty()) { + underwearPaths.push_back(tex); + LOG_INFO(" DBC underwear texture: ", tex); + } + } + foundUnderwear = true; } - foundUnderwear = true; } + } else { + LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures"); } - } else { - LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures"); - } - for (auto& tex : model.textures) { - if (tex.type == 1 && tex.filename.empty()) { - tex.filename = bodySkinPath; - } else if (tex.type == 6 && tex.filename.empty()) { - tex.filename = "Character\\Human\\Hair00_00.blp"; - } else if (tex.type == 8 && tex.filename.empty()) { - if (!underwearPaths.empty()) { - tex.filename = underwearPaths[0]; - } else { - tex.filename = "Character\\Human\\Male\\HumanMaleNakedPelvisSkin00_00.blp"; + for (auto& tex : model.textures) { + if (tex.type == 1 && tex.filename.empty()) { + tex.filename = bodySkinPath; + } else if (tex.type == 6 && tex.filename.empty()) { + tex.filename = "Character\\Human\\Hair00_00.blp"; + } else if (tex.type == 8 && tex.filename.empty()) { + if (!underwearPaths.empty()) { + tex.filename = underwearPaths[0]; + } else { + tex.filename = pelvisPath; + } } } } @@ -640,8 +728,11 @@ void Application::spawnPlayerCharacter() { // e.g. Character\Human\Male\HumanMale0097-00.anim char animFileName[256]; snprintf(animFileName, sizeof(animFileName), - "Character\\Human\\Male\\HumanMale%04u-%02u.anim", - model.sequences[si].id, model.sequences[si].variationIndex); + "%s%s%04u-%02u.anim", + modelDir.c_str(), + baseName.c_str(), + model.sequences[si].id, + model.sequences[si].variationIndex); auto animFileData = assetManager->readFile(animFileName); if (!animFileData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model); @@ -651,29 +742,33 @@ void Application::spawnPlayerCharacter() { charRenderer->loadModel(model, 1); - // Save skin composite state for re-compositing on equipment changes - bodySkinPath_ = bodySkinPath; - underwearPaths_ = underwearPaths; + if (useCharSections) { + // Save skin composite state for re-compositing on equipment changes + bodySkinPath_ = bodySkinPath; + underwearPaths_ = underwearPaths; - - // Composite body skin + underwear overlays - if (!underwearPaths.empty()) { - std::vector layers; - layers.push_back(bodySkinPath); - for (const auto& up : underwearPaths) { - layers.push_back(up); - } - GLuint compositeTex = charRenderer->compositeTextures(layers); - if (compositeTex != 0) { - for (size_t ti = 0; ti < model.textures.size(); ti++) { - if (model.textures[ti].type == 1) { - charRenderer->setModelTexture(1, static_cast(ti), compositeTex); - skinTextureSlotIndex_ = static_cast(ti); - LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+underwear"); - break; + // Composite body skin + underwear overlays + if (!underwearPaths.empty()) { + std::vector layers; + layers.push_back(bodySkinPath); + for (const auto& up : underwearPaths) { + layers.push_back(up); + } + GLuint compositeTex = charRenderer->compositeTextures(layers); + if (compositeTex != 0) { + for (size_t ti = 0; ti < model.textures.size(); ti++) { + if (model.textures[ti].type == 1) { + charRenderer->setModelTexture(1, static_cast(ti), compositeTex); + skinTextureSlotIndex_ = static_cast(ti); + LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+underwear"); + break; + } } } } + } else { + bodySkinPath_.clear(); + underwearPaths_.clear(); } // Find cloak (type-2, Object Skin) texture slot index for (size_t ti = 0; ti < model.textures.size(); ti++) { @@ -685,7 +780,7 @@ void Application::spawnPlayerCharacter() { } loaded = true; - LOG_INFO("Loaded HumanMale M2: ", model.vertices.size(), " verts, ", + LOG_INFO("Loaded character model: ", m2Path, " (", model.vertices.size(), " verts, ", model.bones.size(), " bones, ", model.sequences.size(), " anims, ", model.indices.size(), " indices, ", model.batches.size(), " batches"); // Log all animation sequence IDs @@ -980,12 +1075,6 @@ void Application::startSinglePlayer() { // Enable single-player combat mode on game handler if (gameHandler) { gameHandler->setSinglePlayerMode(true); - // Only init stats with defaults if not already set (e.g. via character creation) - if (gameHandler->getLocalPlayerMaxHealth() == 0) { - uint32_t level = 10; - uint32_t maxHealth = 20 + level * 10; - gameHandler->initLocalPlayerStats(level, maxHealth, maxHealth); - } } // Create world object for single-player @@ -994,9 +1083,44 @@ void Application::startSinglePlayer() { LOG_INFO("Single-player world created"); } - // Populate test inventory for single-player + const game::Character* activeChar = gameHandler ? gameHandler->getActiveCharacter() : nullptr; + if (!activeChar && gameHandler) { + activeChar = gameHandler->getFirstCharacter(); + if (activeChar) { + gameHandler->setActiveCharacterGuid(activeChar->guid); + } + } + if (!activeChar) { + LOG_ERROR("Single-player start: no character selected"); + return; + } + + spRace_ = activeChar->race; + spGender_ = activeChar->gender; + spClass_ = activeChar->characterClass; + spMapId_ = activeChar->mapId; + spZoneId_ = activeChar->zoneId; + spSpawnCanonical_ = core::coords::serverToCanonical(glm::vec3(activeChar->x, activeChar->y, activeChar->z)); + spYawDeg_ = 0.0f; + spPitchDeg_ = -5.0f; + + game::GameHandler::SinglePlayerCreateInfo createInfo; + bool hasCreate = gameHandler && gameHandler->getSinglePlayerCreateInfo(activeChar->race, activeChar->characterClass, createInfo); + if (hasCreate) { + spMapId_ = createInfo.mapId; + spZoneId_ = createInfo.zoneId; + spSpawnCanonical_ = core::coords::serverToCanonical(glm::vec3(createInfo.x, createInfo.y, createInfo.z)); + spYawDeg_ = glm::degrees(createInfo.orientation); + spPitchDeg_ = -5.0f; + spawnSnapToGround = true; + } + if (gameHandler) { - gameHandler->getInventory().populateTestItems(); + gameHandler->setPlayerGuid(activeChar->guid); + uint32_t level = std::max(1, activeChar->level); + uint32_t maxHealth = 20 + level * 10; + gameHandler->initLocalPlayerStats(level, maxHealth, maxHealth); + gameHandler->applySinglePlayerStartData(activeChar->race, activeChar->characterClass); } // Load weapon models for equipped items (after inventory is populated) @@ -1005,11 +1129,11 @@ void Application::startSinglePlayer() { // --- Loading screen: load terrain and wait for streaming before spawning --- const SpawnPreset* spawnPreset = selectSpawnPreset(std::getenv("WOW_SPAWN")); // Canonical WoW coords: +X=North, +Y=West, +Z=Up - glm::vec3 spawnCanonical = spawnPreset ? spawnPreset->spawnCanonical : glm::vec3(62.0f, -9464.0f, 200.0f); - std::string mapName = spawnPreset ? spawnPreset->mapName : "Azeroth"; - float spawnYaw = spawnPreset ? spawnPreset->yawDeg : 0.0f; - float spawnPitch = spawnPreset ? spawnPreset->pitchDeg : -5.0f; - spawnSnapToGround = spawnPreset ? spawnPreset->snapToGround : true; + glm::vec3 spawnCanonical = spawnPreset ? spawnPreset->spawnCanonical : spSpawnCanonical_; + std::string mapName = spawnPreset ? spawnPreset->mapName : mapIdToName(spMapId_); + float spawnYaw = spawnPreset ? spawnPreset->yawDeg : spYawDeg_; + float spawnPitch = spawnPreset ? spawnPreset->pitchDeg : spPitchDeg_; + spawnSnapToGround = spawnPreset ? spawnPreset->snapToGround : spawnSnapToGround; if (auto envSpawnPos = parseVec3Csv(std::getenv("WOW_SPAWN_POS"))) { spawnCanonical = *envSpawnPos; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8302a33f..4456a35a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -54,6 +54,28 @@ struct SinglePlayerLootDb { std::unordered_map itemTemplates; }; +struct SinglePlayerCreateDb { + bool loaded = false; + std::unordered_map rows; +}; + +struct SinglePlayerStartDb { + bool loaded = false; + struct StartItemRow { + uint8_t race = 0; + uint8_t cls = 0; + uint32_t itemId = 0; + int32_t amount = 1; + }; + struct StartSpellRow { + uint32_t raceMask = 0; + uint32_t classMask = 0; + uint32_t spellId = 0; + }; + std::vector items; + std::vector spells; +}; + static std::string trimSql(const std::string& s) { size_t b = 0; while (b < s.size() && std::isspace(static_cast(s[b]))) b++; @@ -319,6 +341,129 @@ static SinglePlayerLootDb& getSinglePlayerLootDb() { return db; } +static SinglePlayerCreateDb& getSinglePlayerCreateDb() { + static SinglePlayerCreateDb db; + if (db.loaded) return db; + + auto base = resolveDbBasePath(); + if (base.empty()) { + db.loaded = true; + return db; + } + + std::filesystem::path basePath = base; + std::filesystem::path createInfoPath = basePath / "playercreateinfo.sql"; + if (!std::filesystem::exists(createInfoPath)) { + auto alt = basePath / "base"; + if (std::filesystem::exists(alt / "playercreateinfo.sql")) { + basePath = alt; + createInfoPath = basePath / "playercreateinfo.sql"; + } + } + + if (!std::filesystem::exists(createInfoPath)) { + db.loaded = true; + return db; + } + + auto cols = loadCreateTableColumns(createInfoPath); + int idxRace = columnIndex(cols, "race"); + int idxClass = columnIndex(cols, "class"); + int idxMap = columnIndex(cols, "map"); + int idxZone = columnIndex(cols, "zone"); + int idxX = columnIndex(cols, "position_x"); + int idxY = columnIndex(cols, "position_y"); + int idxZ = columnIndex(cols, "position_z"); + int idxO = columnIndex(cols, "orientation"); + + std::ifstream in(createInfoPath); + processInsertStatements(in, [&](const std::vector& row) { + if (idxRace < 0 || idxClass < 0 || idxMap < 0 || idxZone < 0 || + idxX < 0 || idxY < 0 || idxZ < 0 || idxO < 0) { + return; + } + if (idxRace >= static_cast(row.size()) || idxClass >= static_cast(row.size())) return; + try { + uint32_t race = static_cast(std::stoul(row[idxRace])); + uint32_t cls = static_cast(std::stoul(row[idxClass])); + GameHandler::SinglePlayerCreateInfo info; + info.mapId = static_cast(std::stoul(row[idxMap])); + info.zoneId = static_cast(std::stoul(row[idxZone])); + info.x = std::stof(row[idxX]); + info.y = std::stof(row[idxY]); + info.z = std::stof(row[idxZ]); + info.orientation = std::stof(row[idxO]); + uint16_t key = static_cast((race << 8) | cls); + db.rows[key] = info; + } catch (const std::exception&) { + } + }); + + db.loaded = true; + LOG_INFO("Single-player create DB loaded from ", createInfoPath.string(), + " (rows=", db.rows.size(), ")"); + return db; +} + +static SinglePlayerStartDb& getSinglePlayerStartDb() { + static SinglePlayerStartDb db; + if (db.loaded) return db; + + auto base = resolveDbBasePath(); + if (base.empty()) { + db.loaded = true; + return db; + } + + std::filesystem::path basePath = base; + std::filesystem::path itemPath = basePath / "playercreateinfo_item.sql"; + std::filesystem::path spellPath = basePath / "playercreateinfo_spell.sql"; + if (!std::filesystem::exists(itemPath) || !std::filesystem::exists(spellPath)) { + auto alt = basePath / "base"; + if (std::filesystem::exists(alt / "playercreateinfo_item.sql")) { + basePath = alt; + itemPath = basePath / "playercreateinfo_item.sql"; + spellPath = basePath / "playercreateinfo_spell.sql"; + } + } + + if (std::filesystem::exists(itemPath)) { + std::ifstream in(itemPath); + processInsertStatements(in, [&](const std::vector& row) { + if (row.size() < 4) return; + try { + SinglePlayerStartDb::StartItemRow r; + r.race = static_cast(std::stoul(row[0])); + r.cls = static_cast(std::stoul(row[1])); + r.itemId = static_cast(std::stoul(row[2])); + r.amount = static_cast(std::stol(row[3])); + db.items.push_back(r); + } catch (const std::exception&) { + } + }); + } + + if (std::filesystem::exists(spellPath)) { + std::ifstream in(spellPath); + processInsertStatements(in, [&](const std::vector& row) { + if (row.size() < 3) return; + try { + SinglePlayerStartDb::StartSpellRow r; + r.raceMask = static_cast(std::stoul(row[0])); + r.classMask = static_cast(std::stoul(row[1])); + r.spellId = static_cast(std::stoul(row[2])); + db.spells.push_back(r); + } catch (const std::exception&) { + } + }); + } + + db.loaded = true; + LOG_INFO("Single-player start DB loaded (items=", db.items.size(), + ", spells=", db.spells.size(), ")"); + return db; +} + } // namespace GameHandler::GameHandler() { @@ -849,15 +994,27 @@ void GameHandler::createCharacter(const CharCreateData& data) { (static_cast(data.hairStyle) << 16) | (static_cast(data.hairColor) << 24); ch.facialFeatures = data.facialHair; - ch.zoneId = 12; // Elwynn Forest default - ch.mapId = 0; - ch.x = -8949.95f; - ch.y = -132.493f; - ch.z = 83.5312f; + SinglePlayerCreateInfo createInfo; + if (getSinglePlayerCreateInfo(data.race, data.characterClass, createInfo)) { + ch.zoneId = createInfo.zoneId; + ch.mapId = createInfo.mapId; + ch.x = createInfo.x; + ch.y = createInfo.y; + ch.z = createInfo.z; + } else { + ch.zoneId = 12; // Elwynn Forest default + ch.mapId = 0; + ch.x = -8949.95f; + ch.y = -132.493f; + ch.z = 83.5312f; + } ch.guildId = 0; ch.flags = 0; ch.pet = {}; characters.push_back(ch); + if (activeCharacterGuid_ == 0) { + activeCharacterGuid_ = ch.guid; + } LOG_INFO("Single-player character created: ", ch.name); // Defer callback to next update() so ImGui frame completes first @@ -910,6 +1067,101 @@ void GameHandler::handleCharCreateResponse(network::Packet& packet) { } } +const Character* GameHandler::getActiveCharacter() const { + if (activeCharacterGuid_ == 0) return nullptr; + for (const auto& ch : characters) { + if (ch.guid == activeCharacterGuid_) return &ch; + } + return nullptr; +} + +const Character* GameHandler::getFirstCharacter() const { + if (characters.empty()) return nullptr; + return &characters.front(); +} + +void GameHandler::setSinglePlayerCharListReady() { + setState(WorldState::CHAR_LIST_RECEIVED); +} + +bool GameHandler::getSinglePlayerCreateInfo(Race race, Class cls, SinglePlayerCreateInfo& out) const { + auto& db = getSinglePlayerCreateDb(); + uint16_t key = static_cast((static_cast(race) << 8) | + static_cast(cls)); + auto it = db.rows.find(key); + if (it == db.rows.end()) return false; + out = it->second; + return true; +} + +void GameHandler::applySinglePlayerStartData(Race race, Class cls) { + inventory = Inventory(); + knownSpells.clear(); + knownSpells.push_back(6603); // Attack + knownSpells.push_back(8690); // Hearthstone + + for (auto& slot : actionBar) { + slot = ActionBarSlot{}; + } + actionBar[0].type = ActionBarSlot::SPELL; + actionBar[0].id = 6603; + actionBar[11].type = ActionBarSlot::SPELL; + actionBar[11].id = 8690; + + auto& startDb = getSinglePlayerStartDb(); + auto& itemDb = getSinglePlayerLootDb().itemTemplates; + + uint8_t raceVal = static_cast(race); + uint8_t classVal = static_cast(cls); + + for (const auto& row : startDb.items) { + if (row.itemId == 0 || row.amount == 0) continue; + if (row.race != 0 && row.race != raceVal) continue; + if (row.cls != 0 && row.cls != classVal) continue; + if (row.amount < 0) continue; + + ItemDef def; + def.itemId = row.itemId; + def.stackCount = static_cast(row.amount); + def.maxStack = def.stackCount; + + auto itTpl = itemDb.find(row.itemId); + if (itTpl != itemDb.end()) { + def.name = itTpl->second.name.empty() + ? ("Item " + std::to_string(row.itemId)) + : itTpl->second.name; + def.quality = static_cast(itTpl->second.quality); + def.inventoryType = itTpl->second.inventoryType; + def.maxStack = std::max(def.maxStack, static_cast(itTpl->second.maxStack)); + } else { + def.name = "Item " + std::to_string(row.itemId); + } + + inventory.addItem(def); + } + + uint32_t raceMask = 1u << (raceVal > 0 ? (raceVal - 1) : 0); + uint32_t classMask = 1u << (classVal > 0 ? (classVal - 1) : 0); + for (const auto& row : startDb.spells) { + if (row.spellId == 0) continue; + if (row.raceMask != 0 && (row.raceMask & raceMask) == 0) continue; + if (row.classMask != 0 && (row.classMask & classMask) == 0) continue; + if (std::find(knownSpells.begin(), knownSpells.end(), row.spellId) == knownSpells.end()) { + knownSpells.push_back(row.spellId); + } + } + + // Auto-populate action bar with known spells + int slot = 1; + for (uint32_t spellId : knownSpells) { + if (spellId == 6603 || spellId == 8690) continue; + if (slot >= 11) break; + actionBar[slot].type = ActionBarSlot::SPELL; + actionBar[slot].id = spellId; + slot++; + } +} + void GameHandler::selectCharacter(uint64_t characterGuid) { if (state != WorldState::CHAR_LIST_RECEIVED) { LOG_WARNING("Cannot select character in state: ", (int)state);