diff --git a/CMakeLists.txt b/CMakeLists.txt index e1113df0..549c3b80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ find_package(OpenGL REQUIRED) find_package(GLEW REQUIRED) find_package(OpenSSL REQUIRED) find_package(Threads REQUIRED) +find_package(SQLite3 REQUIRED) # GLM (header-only math library) find_package(glm QUIET) @@ -131,6 +132,7 @@ set(WOWEE_SOURCES src/rendering/weather.cpp src/rendering/lightning.cpp src/rendering/character_renderer.cpp + src/rendering/character_preview.cpp src/rendering/wmo_renderer.cpp src/rendering/m2_renderer.cpp src/rendering/minimap.cpp @@ -221,6 +223,7 @@ set(WOWEE_HEADERS include/rendering/swim_effects.hpp include/rendering/world_map.hpp include/rendering/character_renderer.hpp + include/rendering/character_preview.hpp include/rendering/wmo_renderer.hpp include/rendering/loading_screen.hpp @@ -254,6 +257,14 @@ target_link_libraries(wowee PRIVATE Threads::Threads ) +# SQLite +if (TARGET SQLite::SQLite3) + target_link_libraries(wowee PRIVATE SQLite::SQLite3) +else() + target_include_directories(wowee PRIVATE ${SQLite3_INCLUDE_DIRS}) + target_link_libraries(wowee PRIVATE ${SQLite3_LIBRARIES}) +endif() + # Platform-specific libraries if(WIN32) target_link_libraries(wowee PRIVATE ws2_32) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index bc864a8c..6b0267bc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -224,6 +224,11 @@ public: bool isSinglePlayerMode() const { return singlePlayerMode_; } void simulateMotd(const std::vector& lines); void applySinglePlayerStartData(Race race, Class cls); + bool loadSinglePlayerCharacterState(uint64_t guid); + void notifyInventoryChanged(); + void notifyEquipmentChanged(); + void notifyQuestStateChanged(); + void flushSinglePlayerSave(); // NPC death callback (single-player) using NpcDeathCallback = std::function; @@ -581,6 +586,36 @@ private: void handleNpcDeath(uint64_t guid); void aggroNpc(uint64_t guid); bool isNpcAggroed(uint64_t guid) const; + + // ---- Single-player persistence ---- + enum SinglePlayerDirty : uint32_t { + SP_DIRTY_NONE = 0, + SP_DIRTY_CHAR = 1 << 0, + SP_DIRTY_INVENTORY = 1 << 1, + SP_DIRTY_SPELLS = 1 << 2, + SP_DIRTY_ACTIONBAR = 1 << 3, + SP_DIRTY_AURAS = 1 << 4, + SP_DIRTY_QUESTS = 1 << 5, + SP_DIRTY_MONEY = 1 << 6, + SP_DIRTY_XP = 1 << 7, + SP_DIRTY_POSITION = 1 << 8, + SP_DIRTY_STATS = 1 << 9, + SP_DIRTY_ALL = 0xFFFFFFFFu + }; + void markSinglePlayerDirty(uint32_t flags, bool highPriority); + void loadSinglePlayerCharacters(); + void saveSinglePlayerCharacterState(bool force); + + uint32_t spDirtyFlags_ = SP_DIRTY_NONE; + bool spDirtyHighPriority_ = false; + float spDirtyTimer_ = 0.0f; + float spPeriodicTimer_ = 0.0f; + float spLastDirtyX_ = 0.0f; + float spLastDirtyY_ = 0.0f; + float spLastDirtyZ_ = 0.0f; + float spLastDirtyOrientation_ = 0.0f; + std::unordered_map spHasState_; + std::unordered_map spSavedOrientation_; }; } // namespace game diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 39f218ad..ad1c62a8 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -15,11 +15,14 @@ public: /// Returns true if equipment changed since last call, and clears the flag. bool consumeEquipmentDirty() { bool d = equipmentDirty; equipmentDirty = false; return d; } + /// Returns true if any inventory slot changed since last call, and clears the flag. + bool consumeInventoryDirty() { bool d = inventoryDirty; inventoryDirty = false; return d; } private: bool open = false; bool bKeyWasDown = false; bool equipmentDirty = false; + bool inventoryDirty = false; // Drag-and-drop held item state bool holdingItem = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 4c8c7c03..2205d1d1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -76,54 +76,7 @@ const char* Application::mapIdToName(uint32_t mapId) { } 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_); + return game::getPlayerModelPath(spRace_, spGender_); } namespace { @@ -412,6 +365,9 @@ void Application::update(float deltaTime) { if (gameHandler) { gameHandler->update(deltaTime); } + if (uiManager) { + uiManager->getCharacterCreateScreen().update(deltaTime); + } break; case AppState::CHARACTER_SELECTION: @@ -522,6 +478,7 @@ void Application::setupUICallbacks() { gameHandler->setSinglePlayerCharListReady(); } uiManager->getCharacterCreateScreen().reset(); + uiManager->getCharacterCreateScreen().initializePreview(assetManager.get()); setState(AppState::CHARACTER_CREATION); }); @@ -596,6 +553,7 @@ void Application::setupUICallbacks() { // "Create Character" button on character screen uiManager->getCharacterScreen().setOnCreateCharacter([this]() { uiManager->getCharacterCreateScreen().reset(); + uiManager->getCharacterCreateScreen().initializePreview(assetManager.get()); setState(AppState::CHARACTER_CREATION); }); } @@ -1104,23 +1062,31 @@ void Application::startSinglePlayer() { 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; - } - + bool loadedState = false; if (gameHandler) { 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); + loadedState = gameHandler->loadSinglePlayerCharacterState(activeChar->guid); + if (loadedState) { + const auto& movement = gameHandler->getMovementInfo(); + spSpawnCanonical_ = glm::vec3(movement.x, movement.y, movement.z); + spYawDeg_ = glm::degrees(movement.orientation); + spawnSnapToGround = true; + } else { + game::GameHandler::SinglePlayerCreateInfo createInfo; + bool hasCreate = 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; + } + 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) @@ -1151,6 +1117,12 @@ void Application::startSinglePlayer() { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->setDefaultSpawn(spawnRender, spawnYaw, spawnPitch); } + + if (gameHandler && !loadedState) { + gameHandler->setPosition(spawnCanonical.x, spawnCanonical.y, spawnCanonical.z); + gameHandler->setOrientation(glm::radians(spawnYaw - 90.0f)); + gameHandler->flushSinglePlayerSave(); + } if (spawnPreset) { LOG_INFO("Single-player spawn preset: ", spawnPreset->label, " canonical=(", diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e90611c1..f3867aea 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15,6 +15,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -84,6 +85,129 @@ struct SinglePlayerStartDb { std::vector actions; }; +struct SinglePlayerSqlite { + sqlite3* db = nullptr; + std::filesystem::path path; + + bool open() { + if (db) return true; + path = std::filesystem::path("saves"); + std::error_code ec; + std::filesystem::create_directories(path, ec); + path /= "singleplayer.db"; + if (sqlite3_open(path.string().c_str(), &db) != SQLITE_OK) { + db = nullptr; + return false; + } + sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr); + sqlite3_exec(db, "PRAGMA synchronous=NORMAL;", nullptr, nullptr, nullptr); + return true; + } + + void close() { + if (db) { + sqlite3_close(db); + db = nullptr; + } + } + + bool exec(const char* sql) const { + if (!db) return false; + char* err = nullptr; + int rc = sqlite3_exec(db, sql, nullptr, nullptr, &err); + if (err) sqlite3_free(err); + return rc == SQLITE_OK; + } + + bool ensureSchema() const { + static const char* kSchema = + "CREATE TABLE IF NOT EXISTS characters (" + " guid INTEGER PRIMARY KEY," + " name TEXT," + " race INTEGER," + " \"class\" INTEGER," + " gender INTEGER," + " level INTEGER," + " appearance_bytes INTEGER," + " facial_features INTEGER," + " zone INTEGER," + " map INTEGER," + " position_x REAL," + " position_y REAL," + " position_z REAL," + " orientation REAL," + " money INTEGER," + " xp INTEGER," + " health INTEGER," + " max_health INTEGER," + " has_state INTEGER DEFAULT 0" + ");" + "CREATE TABLE IF NOT EXISTS character_inventory (" + " guid INTEGER," + " location INTEGER," + " slot INTEGER," + " item_id INTEGER," + " name TEXT," + " quality INTEGER," + " inventory_type INTEGER," + " stack_count INTEGER," + " max_stack INTEGER," + " bag_slots INTEGER," + " armor INTEGER," + " stamina INTEGER," + " strength INTEGER," + " agility INTEGER," + " intellect INTEGER," + " spirit INTEGER," + " display_info_id INTEGER," + " subclass_name TEXT," + " PRIMARY KEY (guid, location, slot)" + ");" + "CREATE TABLE IF NOT EXISTS character_spell (" + " guid INTEGER," + " spell INTEGER," + " PRIMARY KEY (guid, spell)" + ");" + "CREATE TABLE IF NOT EXISTS character_action (" + " guid INTEGER," + " slot INTEGER," + " type INTEGER," + " action INTEGER," + " PRIMARY KEY (guid, slot)" + ");" + "CREATE TABLE IF NOT EXISTS character_aura (" + " guid INTEGER," + " slot INTEGER," + " spell INTEGER," + " flags INTEGER," + " level INTEGER," + " charges INTEGER," + " duration_ms INTEGER," + " max_duration_ms INTEGER," + " caster_guid INTEGER," + " PRIMARY KEY (guid, slot)" + ");" + "CREATE TABLE IF NOT EXISTS character_queststatus (" + " guid INTEGER," + " quest INTEGER," + " status INTEGER," + " progress INTEGER," + " PRIMARY KEY (guid, quest)" + ");"; + return exec(kSchema); + } +}; + +static SinglePlayerSqlite& getSinglePlayerSqlite() { + static SinglePlayerSqlite sp; + if (!sp.db) { + if (sp.open()) { + sp.ensureSchema(); + } + } + return sp; +} + static uint32_t removeItemsFromInventory(Inventory& inventory, uint32_t itemId, uint32_t amount) { if (itemId == 0 || amount == 0) return 0; uint32_t remaining = amount; @@ -618,6 +742,9 @@ bool GameHandler::connect(const std::string& host, } void GameHandler::disconnect() { + if (singlePlayerMode_) { + flushSinglePlayerSave(); + } if (socket) { socket->disconnect(); socket.reset(); @@ -701,6 +828,22 @@ void GameHandler::update(float deltaTime) { updateNpcAggro(deltaTime); } } + + if (singlePlayerMode_) { + if (spDirtyFlags_ != SP_DIRTY_NONE) { + spDirtyTimer_ += deltaTime; + spPeriodicTimer_ += deltaTime; + bool due = false; + if (spDirtyHighPriority_ && spDirtyTimer_ >= 0.5f) { + due = true; + } else if (spPeriodicTimer_ >= 30.0f) { + due = true; + } + if (due) { + saveSinglePlayerCharacterState(false); + } + } + } } void GameHandler::handlePacket(network::Packet& packet) { @@ -1006,6 +1149,11 @@ void GameHandler::handleAuthResponse(network::Packet& packet) { } void GameHandler::requestCharacterList() { + if (singlePlayerMode_) { + loadSinglePlayerCharacters(); + setState(WorldState::CHAR_LIST_RECEIVED); + return; + } if (state != WorldState::READY && state != WorldState::AUTHENTICATED) { LOG_WARNING("Cannot request character list in state: ", (int)state); return; @@ -1063,7 +1211,11 @@ void GameHandler::createCharacter(const CharCreateData& data) { if (singlePlayerMode_) { // Create character locally Character ch; - ch.guid = 0x0000000100000001ULL + characters.size(); + uint64_t nextGuid = 0x0000000100000001ULL; + for (const auto& existing : characters) { + nextGuid = std::max(nextGuid, existing.guid + 1); + } + ch.guid = nextGuid; ch.name = data.name; ch.race = data.race; ch.characterClass = data.characterClass; @@ -1092,6 +1244,43 @@ void GameHandler::createCharacter(const CharCreateData& data) { ch.flags = 0; ch.pet = {}; characters.push_back(ch); + spHasState_[ch.guid] = false; + spSavedOrientation_[ch.guid] = 0.0f; + + // Persist to single-player DB + auto& sp = getSinglePlayerSqlite(); + if (sp.db) { + const char* sql = + "INSERT OR REPLACE INTO characters " + "(guid, name, race, \"class\", gender, level, appearance_bytes, facial_features, zone, map, " + "position_x, position_y, position_z, orientation, money, xp, health, max_health, has_state) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(sp.db, sql, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, static_cast(ch.guid)); + sqlite3_bind_text(stmt, 2, ch.name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 3, static_cast(ch.race)); + sqlite3_bind_int(stmt, 4, static_cast(ch.characterClass)); + sqlite3_bind_int(stmt, 5, static_cast(ch.gender)); + sqlite3_bind_int(stmt, 6, static_cast(ch.level)); + sqlite3_bind_int(stmt, 7, static_cast(ch.appearanceBytes)); + sqlite3_bind_int(stmt, 8, static_cast(ch.facialFeatures)); + sqlite3_bind_int(stmt, 9, static_cast(ch.zoneId)); + sqlite3_bind_int(stmt, 10, static_cast(ch.mapId)); + sqlite3_bind_double(stmt, 11, ch.x); + sqlite3_bind_double(stmt, 12, ch.y); + sqlite3_bind_double(stmt, 13, ch.z); + sqlite3_bind_double(stmt, 14, 0.0); + sqlite3_bind_int64(stmt, 15, 0); + sqlite3_bind_int(stmt, 16, 0); + sqlite3_bind_int(stmt, 17, 0); + sqlite3_bind_int(stmt, 18, 0); + sqlite3_bind_int(stmt, 19, 0); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + } + } + if (activeCharacterGuid_ == 0) { activeCharacterGuid_ = ch.guid; } @@ -1161,6 +1350,7 @@ const Character* GameHandler::getFirstCharacter() const { } void GameHandler::setSinglePlayerCharListReady() { + loadSinglePlayerCharacters(); setState(WorldState::CHAR_LIST_RECEIVED); } @@ -1174,6 +1364,254 @@ bool GameHandler::getSinglePlayerCreateInfo(Race race, Class cls, SinglePlayerCr return true; } +void GameHandler::notifyInventoryChanged() { + markSinglePlayerDirty(SP_DIRTY_INVENTORY, true); +} + +void GameHandler::notifyEquipmentChanged() { + markSinglePlayerDirty(SP_DIRTY_INVENTORY, true); + markSinglePlayerDirty(SP_DIRTY_STATS, true); +} + +void GameHandler::notifyQuestStateChanged() { + markSinglePlayerDirty(SP_DIRTY_QUESTS, true); +} + +void GameHandler::markSinglePlayerDirty(uint32_t flags, bool highPriority) { + if (!singlePlayerMode_) return; + spDirtyFlags_ |= flags; + if (highPriority) { + spDirtyHighPriority_ = true; + spDirtyTimer_ = 0.0f; + } +} + +void GameHandler::loadSinglePlayerCharacters() { + if (!singlePlayerMode_) return; + auto& sp = getSinglePlayerSqlite(); + if (!sp.db) return; + + characters.clear(); + spHasState_.clear(); + spSavedOrientation_.clear(); + + const char* sql = + "SELECT guid, name, race, \"class\", gender, level, appearance_bytes, facial_features, " + "zone, map, position_x, position_y, position_z, orientation, has_state " + "FROM characters ORDER BY guid;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(sp.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; + + while (sqlite3_step(stmt) == SQLITE_ROW) { + Character ch; + ch.guid = static_cast(sqlite3_column_int64(stmt, 0)); + const unsigned char* nameText = sqlite3_column_text(stmt, 1); + ch.name = nameText ? reinterpret_cast(nameText) : ""; + ch.race = static_cast(sqlite3_column_int(stmt, 2)); + ch.characterClass = static_cast(sqlite3_column_int(stmt, 3)); + ch.gender = static_cast(sqlite3_column_int(stmt, 4)); + ch.level = static_cast(sqlite3_column_int(stmt, 5)); + ch.appearanceBytes = static_cast(sqlite3_column_int(stmt, 6)); + ch.facialFeatures = static_cast(sqlite3_column_int(stmt, 7)); + ch.zoneId = static_cast(sqlite3_column_int(stmt, 8)); + ch.mapId = static_cast(sqlite3_column_int(stmt, 9)); + ch.x = static_cast(sqlite3_column_double(stmt, 10)); + ch.y = static_cast(sqlite3_column_double(stmt, 11)); + ch.z = static_cast(sqlite3_column_double(stmt, 12)); + float orientation = static_cast(sqlite3_column_double(stmt, 13)); + int hasState = sqlite3_column_int(stmt, 14); + + characters.push_back(ch); + spHasState_[ch.guid] = (hasState != 0); + spSavedOrientation_[ch.guid] = orientation; + } + sqlite3_finalize(stmt); +} + +bool GameHandler::loadSinglePlayerCharacterState(uint64_t guid) { + if (!singlePlayerMode_) return false; + auto& sp = getSinglePlayerSqlite(); + if (!sp.db) return false; + + const char* sqlChar = + "SELECT level, zone, map, position_x, position_y, position_z, orientation, money, xp, health, max_health, has_state " + "FROM characters WHERE guid=?;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(sp.db, sqlChar, -1, &stmt, nullptr) != SQLITE_OK) return false; + sqlite3_bind_int64(stmt, 1, static_cast(guid)); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return false; + } + + uint32_t level = static_cast(sqlite3_column_int(stmt, 0)); + uint32_t zone = static_cast(sqlite3_column_int(stmt, 1)); + uint32_t map = static_cast(sqlite3_column_int(stmt, 2)); + float posX = static_cast(sqlite3_column_double(stmt, 3)); + float posY = static_cast(sqlite3_column_double(stmt, 4)); + float posZ = static_cast(sqlite3_column_double(stmt, 5)); + float orientation = static_cast(sqlite3_column_double(stmt, 6)); + uint64_t money = static_cast(sqlite3_column_int64(stmt, 7)); + uint32_t xp = static_cast(sqlite3_column_int(stmt, 8)); + uint32_t health = static_cast(sqlite3_column_int(stmt, 9)); + uint32_t maxHealth = static_cast(sqlite3_column_int(stmt, 10)); + bool hasState = sqlite3_column_int(stmt, 11) != 0; + sqlite3_finalize(stmt); + + spHasState_[guid] = hasState; + spSavedOrientation_[guid] = orientation; + if (!hasState) return false; + + // Update character list entry + for (auto& ch : characters) { + if (ch.guid == guid) { + ch.level = static_cast(std::max(1, level)); + ch.zoneId = zone; + ch.mapId = map; + ch.x = posX; + ch.y = posY; + ch.z = posZ; + break; + } + } + + // Load inventory + inventory = Inventory(); + const char* sqlInv = + "SELECT location, slot, item_id, name, quality, inventory_type, stack_count, max_stack, bag_slots, " + "armor, stamina, strength, agility, intellect, spirit, display_info_id, subclass_name " + "FROM character_inventory WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, sqlInv, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, static_cast(guid)); + while (sqlite3_step(stmt) == SQLITE_ROW) { + int location = sqlite3_column_int(stmt, 0); + int slot = sqlite3_column_int(stmt, 1); + ItemDef def; + def.itemId = static_cast(sqlite3_column_int(stmt, 2)); + const unsigned char* nameText = sqlite3_column_text(stmt, 3); + def.name = nameText ? reinterpret_cast(nameText) : ""; + def.quality = static_cast(sqlite3_column_int(stmt, 4)); + def.inventoryType = static_cast(sqlite3_column_int(stmt, 5)); + def.stackCount = static_cast(sqlite3_column_int(stmt, 6)); + def.maxStack = static_cast(sqlite3_column_int(stmt, 7)); + def.bagSlots = static_cast(sqlite3_column_int(stmt, 8)); + def.armor = static_cast(sqlite3_column_int(stmt, 9)); + def.stamina = static_cast(sqlite3_column_int(stmt, 10)); + def.strength = static_cast(sqlite3_column_int(stmt, 11)); + def.agility = static_cast(sqlite3_column_int(stmt, 12)); + def.intellect = static_cast(sqlite3_column_int(stmt, 13)); + def.spirit = static_cast(sqlite3_column_int(stmt, 14)); + def.displayInfoId = static_cast(sqlite3_column_int(stmt, 15)); + const unsigned char* subclassText = sqlite3_column_text(stmt, 16); + def.subclassName = subclassText ? reinterpret_cast(subclassText) : ""; + + if (location == 0) { + inventory.setBackpackSlot(slot, def); + } else if (location == 1) { + inventory.setEquipSlot(static_cast(slot), def); + } else if (location == 2) { + int bagIndex = slot / Inventory::MAX_BAG_SIZE; + int bagSlot = slot % Inventory::MAX_BAG_SIZE; + inventory.setBagSlot(bagIndex, bagSlot, def); + } + } + sqlite3_finalize(stmt); + } + + // Load spells + knownSpells.clear(); + const char* sqlSpell = "SELECT spell FROM character_spell WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, sqlSpell, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, static_cast(guid)); + while (sqlite3_step(stmt) == SQLITE_ROW) { + uint32_t spellId = static_cast(sqlite3_column_int(stmt, 0)); + if (spellId != 0) knownSpells.push_back(spellId); + } + sqlite3_finalize(stmt); + } + if (std::find(knownSpells.begin(), knownSpells.end(), 6603) == knownSpells.end()) { + knownSpells.push_back(6603); + } + if (std::find(knownSpells.begin(), knownSpells.end(), 8690) == knownSpells.end()) { + knownSpells.push_back(8690); + } + + // Load action bar + for (auto& slot : actionBar) slot = ActionBarSlot{}; + bool hasActionRows = false; + const char* sqlAction = "SELECT slot, type, action FROM character_action WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, sqlAction, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, static_cast(guid)); + while (sqlite3_step(stmt) == SQLITE_ROW) { + int slot = sqlite3_column_int(stmt, 0); + if (slot < 0 || slot >= static_cast(actionBar.size())) continue; + actionBar[slot].type = static_cast(sqlite3_column_int(stmt, 1)); + actionBar[slot].id = static_cast(sqlite3_column_int(stmt, 2)); + hasActionRows = true; + } + sqlite3_finalize(stmt); + } + if (!hasActionRows) { + actionBar[0].type = ActionBarSlot::SPELL; + actionBar[0].id = 6603; + actionBar[11].type = ActionBarSlot::SPELL; + actionBar[11].id = 8690; + 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++; + } + } + + // Load auras + playerAuras.clear(); + const char* sqlAura = + "SELECT slot, spell, flags, level, charges, duration_ms, max_duration_ms, caster_guid " + "FROM character_aura WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, sqlAura, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, static_cast(guid)); + while (sqlite3_step(stmt) == SQLITE_ROW) { + uint8_t slot = static_cast(sqlite3_column_int(stmt, 0)); + AuraSlot aura; + aura.spellId = static_cast(sqlite3_column_int(stmt, 1)); + aura.flags = static_cast(sqlite3_column_int(stmt, 2)); + aura.level = static_cast(sqlite3_column_int(stmt, 3)); + aura.charges = static_cast(sqlite3_column_int(stmt, 4)); + aura.durationMs = static_cast(sqlite3_column_int(stmt, 5)); + aura.maxDurationMs = static_cast(sqlite3_column_int(stmt, 6)); + aura.casterGuid = static_cast(sqlite3_column_int64(stmt, 7)); + while (playerAuras.size() <= slot) playerAuras.push_back(AuraSlot{}); + playerAuras[slot] = aura; + } + sqlite3_finalize(stmt); + } + + // Apply money, xp, stats + playerMoneyCopper_ = money; + playerXp_ = xp; + localPlayerLevel_ = std::max(1, level); + localPlayerHealth_ = std::max(1, health); + localPlayerMaxHealth_ = std::max(localPlayerHealth_, maxHealth); + playerNextLevelXp_ = xpForLevel(localPlayerLevel_); + + // Seed movement info for spawn + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(posX, posY, posZ)); + movementInfo.x = canonical.x; + movementInfo.y = canonical.y; + movementInfo.z = canonical.z; + movementInfo.orientation = orientation; + + spLastDirtyX_ = movementInfo.x; + spLastDirtyY_ = movementInfo.y; + spLastDirtyZ_ = movementInfo.z; + spLastDirtyOrientation_ = movementInfo.orientation; + + return true; +} + void GameHandler::applySinglePlayerStartData(Race race, Class cls) { inventory = Inventory(); knownSpells.clear(); @@ -1274,6 +1712,209 @@ void GameHandler::applySinglePlayerStartData(Race race, Class cls) { slot++; } } + + markSinglePlayerDirty(SP_DIRTY_INVENTORY | SP_DIRTY_SPELLS | SP_DIRTY_ACTIONBAR | + SP_DIRTY_STATS | SP_DIRTY_XP | SP_DIRTY_MONEY, true); +} + +void GameHandler::saveSinglePlayerCharacterState(bool force) { + if (!singlePlayerMode_) return; + if (activeCharacterGuid_ == 0) return; + if (!force && spDirtyFlags_ == SP_DIRTY_NONE) return; + + auto& sp = getSinglePlayerSqlite(); + if (!sp.db) return; + + const Character* active = getActiveCharacter(); + if (!active) return; + + sqlite3_exec(sp.db, "BEGIN TRANSACTION;", nullptr, nullptr, nullptr); + + const char* updateCharSql = + "UPDATE characters SET level=?, zone=?, map=?, position_x=?, position_y=?, position_z=?, orientation=?, " + "money=?, xp=?, health=?, max_health=?, has_state=1 WHERE guid=?;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(sp.db, updateCharSql, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_int(stmt, 1, static_cast(localPlayerLevel_)); + sqlite3_bind_int(stmt, 2, static_cast(active->zoneId)); + sqlite3_bind_int(stmt, 3, static_cast(active->mapId)); + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z)); + sqlite3_bind_double(stmt, 4, serverPos.x); + sqlite3_bind_double(stmt, 5, serverPos.y); + sqlite3_bind_double(stmt, 6, serverPos.z); + sqlite3_bind_double(stmt, 7, movementInfo.orientation); + sqlite3_bind_int64(stmt, 8, static_cast(playerMoneyCopper_)); + sqlite3_bind_int(stmt, 9, static_cast(playerXp_)); + sqlite3_bind_int(stmt, 10, static_cast(localPlayerHealth_)); + sqlite3_bind_int(stmt, 11, static_cast(localPlayerMaxHealth_)); + sqlite3_bind_int64(stmt, 12, static_cast(activeCharacterGuid_)); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + } + + spHasState_[activeCharacterGuid_] = true; + spSavedOrientation_[activeCharacterGuid_] = movementInfo.orientation; + + sqlite3_stmt* del = nullptr; + const char* delInv = "DELETE FROM character_inventory WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, delInv, -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(del, 1, static_cast(activeCharacterGuid_)); + sqlite3_step(del); + sqlite3_finalize(del); + } + const char* delSpell = "DELETE FROM character_spell WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, delSpell, -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(del, 1, static_cast(activeCharacterGuid_)); + sqlite3_step(del); + sqlite3_finalize(del); + } + const char* delAction = "DELETE FROM character_action WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, delAction, -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(del, 1, static_cast(activeCharacterGuid_)); + sqlite3_step(del); + sqlite3_finalize(del); + } + const char* delAura = "DELETE FROM character_aura WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, delAura, -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(del, 1, static_cast(activeCharacterGuid_)); + sqlite3_step(del); + sqlite3_finalize(del); + } + const char* delQuest = "DELETE FROM character_queststatus WHERE guid=?;"; + if (sqlite3_prepare_v2(sp.db, delQuest, -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(del, 1, static_cast(activeCharacterGuid_)); + sqlite3_step(del); + sqlite3_finalize(del); + } + + const char* insInv = + "INSERT INTO character_inventory " + "(guid, location, slot, item_id, name, quality, inventory_type, stack_count, max_stack, bag_slots, " + "armor, stamina, strength, agility, intellect, spirit, display_info_id, subclass_name) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"; + if (sqlite3_prepare_v2(sp.db, insInv, -1, &stmt, nullptr) == SQLITE_OK) { + for (int i = 0; i < Inventory::BACKPACK_SLOTS; i++) { + const ItemSlot& slot = inventory.getBackpackSlot(i); + if (slot.empty()) continue; + sqlite3_bind_int64(stmt, 1, static_cast(activeCharacterGuid_)); + sqlite3_bind_int(stmt, 2, 0); + sqlite3_bind_int(stmt, 3, i); + sqlite3_bind_int(stmt, 4, static_cast(slot.item.itemId)); + sqlite3_bind_text(stmt, 5, slot.item.name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 6, static_cast(slot.item.quality)); + sqlite3_bind_int(stmt, 7, static_cast(slot.item.inventoryType)); + sqlite3_bind_int(stmt, 8, static_cast(slot.item.stackCount)); + sqlite3_bind_int(stmt, 9, static_cast(slot.item.maxStack)); + sqlite3_bind_int(stmt, 10, static_cast(slot.item.bagSlots)); + sqlite3_bind_int(stmt, 11, static_cast(slot.item.armor)); + sqlite3_bind_int(stmt, 12, static_cast(slot.item.stamina)); + sqlite3_bind_int(stmt, 13, static_cast(slot.item.strength)); + sqlite3_bind_int(stmt, 14, static_cast(slot.item.agility)); + sqlite3_bind_int(stmt, 15, static_cast(slot.item.intellect)); + sqlite3_bind_int(stmt, 16, static_cast(slot.item.spirit)); + sqlite3_bind_int(stmt, 17, static_cast(slot.item.displayInfoId)); + sqlite3_bind_text(stmt, 18, slot.item.subclassName.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; i++) { + EquipSlot eq = static_cast(i); + const ItemSlot& slot = inventory.getEquipSlot(eq); + if (slot.empty()) continue; + sqlite3_bind_int64(stmt, 1, static_cast(activeCharacterGuid_)); + sqlite3_bind_int(stmt, 2, 1); + sqlite3_bind_int(stmt, 3, i); + sqlite3_bind_int(stmt, 4, static_cast(slot.item.itemId)); + sqlite3_bind_text(stmt, 5, slot.item.name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 6, static_cast(slot.item.quality)); + sqlite3_bind_int(stmt, 7, static_cast(slot.item.inventoryType)); + sqlite3_bind_int(stmt, 8, static_cast(slot.item.stackCount)); + sqlite3_bind_int(stmt, 9, static_cast(slot.item.maxStack)); + sqlite3_bind_int(stmt, 10, static_cast(slot.item.bagSlots)); + sqlite3_bind_int(stmt, 11, static_cast(slot.item.armor)); + sqlite3_bind_int(stmt, 12, static_cast(slot.item.stamina)); + sqlite3_bind_int(stmt, 13, static_cast(slot.item.strength)); + sqlite3_bind_int(stmt, 14, static_cast(slot.item.agility)); + sqlite3_bind_int(stmt, 15, static_cast(slot.item.intellect)); + sqlite3_bind_int(stmt, 16, static_cast(slot.item.spirit)); + sqlite3_bind_int(stmt, 17, static_cast(slot.item.displayInfoId)); + sqlite3_bind_text(stmt, 18, slot.item.subclassName.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + sqlite3_finalize(stmt); + } + + const char* insSpell = "INSERT INTO character_spell (guid, spell) VALUES (?,?);"; + if (sqlite3_prepare_v2(sp.db, insSpell, -1, &stmt, nullptr) == SQLITE_OK) { + for (uint32_t spellId : knownSpells) { + sqlite3_bind_int64(stmt, 1, static_cast(activeCharacterGuid_)); + sqlite3_bind_int(stmt, 2, static_cast(spellId)); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + sqlite3_finalize(stmt); + } + + const char* insAction = "INSERT INTO character_action (guid, slot, type, action) VALUES (?,?,?,?);"; + if (sqlite3_prepare_v2(sp.db, insAction, -1, &stmt, nullptr) == SQLITE_OK) { + for (int i = 0; i < static_cast(actionBar.size()); i++) { + const auto& slot = actionBar[i]; + if (slot.type == ActionBarSlot::EMPTY || slot.id == 0) continue; + sqlite3_bind_int64(stmt, 1, static_cast(activeCharacterGuid_)); + sqlite3_bind_int(stmt, 2, i); + sqlite3_bind_int(stmt, 3, static_cast(slot.type)); + sqlite3_bind_int(stmt, 4, static_cast(slot.id)); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + sqlite3_finalize(stmt); + } + + const char* insAura = + "INSERT INTO character_aura (guid, slot, spell, flags, level, charges, duration_ms, max_duration_ms, caster_guid) " + "VALUES (?,?,?,?,?,?,?,?,?);"; + if (sqlite3_prepare_v2(sp.db, insAura, -1, &stmt, nullptr) == SQLITE_OK) { + for (size_t i = 0; i < playerAuras.size(); i++) { + const auto& aura = playerAuras[i]; + if (aura.spellId == 0) continue; + sqlite3_bind_int64(stmt, 1, static_cast(activeCharacterGuid_)); + sqlite3_bind_int(stmt, 2, static_cast(i)); + sqlite3_bind_int(stmt, 3, static_cast(aura.spellId)); + sqlite3_bind_int(stmt, 4, static_cast(aura.flags)); + sqlite3_bind_int(stmt, 5, static_cast(aura.level)); + sqlite3_bind_int(stmt, 6, static_cast(aura.charges)); + sqlite3_bind_int(stmt, 7, static_cast(aura.durationMs)); + sqlite3_bind_int(stmt, 8, static_cast(aura.maxDurationMs)); + sqlite3_bind_int64(stmt, 9, static_cast(aura.casterGuid)); + sqlite3_step(stmt); + sqlite3_reset(stmt); + } + sqlite3_finalize(stmt); + } + + sqlite3_exec(sp.db, "COMMIT;", nullptr, nullptr, nullptr); + + spDirtyFlags_ = SP_DIRTY_NONE; + spDirtyHighPriority_ = false; + spDirtyTimer_ = 0.0f; + spPeriodicTimer_ = 0.0f; + + // Update cached character list position/level for UI. + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z)); + for (auto& ch : characters) { + if (ch.guid == activeCharacterGuid_) { + ch.level = static_cast(localPlayerLevel_); + ch.x = serverPos.x; + ch.y = serverPos.y; + ch.z = serverPos.z; + break; + } + } +} + +void GameHandler::flushSinglePlayerSave() { + saveSinglePlayerCharacterState(true); } void GameHandler::selectCharacter(uint64_t characterGuid) { @@ -1496,10 +2137,29 @@ void GameHandler::setPosition(float x, float y, float z) { movementInfo.x = x; movementInfo.y = y; movementInfo.z = z; + if (singlePlayerMode_) { + float dx = x - spLastDirtyX_; + float dy = y - spLastDirtyY_; + float dz = z - spLastDirtyZ_; + float distSq = dx * dx + dy * dy + dz * dz; + if (distSq >= 1.0f) { + spLastDirtyX_ = x; + spLastDirtyY_ = y; + spLastDirtyZ_ = z; + markSinglePlayerDirty(SP_DIRTY_POSITION, false); + } + } } void GameHandler::setOrientation(float orientation) { movementInfo.orientation = orientation; + if (singlePlayerMode_) { + float diff = std::fabs(orientation - spLastDirtyOrientation_); + if (diff >= 0.1f) { + spLastDirtyOrientation_ = orientation; + markSinglePlayerDirty(SP_DIRTY_POSITION, false); + } + } } void GameHandler::handleUpdateObject(network::Packet& packet) { @@ -2073,6 +2733,7 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; actionBar[slot].type = type; actionBar[slot].id = id; + markSinglePlayerDirty(SP_DIRTY_ACTIONBAR, true); } float GameHandler::getSpellCooldown(uint32_t spellId) const { @@ -2207,11 +2868,15 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { (*auraList)[slot] = aura; } } + if (singlePlayerMode_ && data.guid == playerGuid) { + markSinglePlayerDirty(SP_DIRTY_AURAS, true); + } } void GameHandler::handleLearnedSpell(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); knownSpells.push_back(spellId); + markSinglePlayerDirty(SP_DIRTY_SPELLS, true); LOG_INFO("Learned spell: ", spellId); } @@ -2220,6 +2885,7 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { knownSpells.erase( std::remove(knownSpells.begin(), knownSpells.end(), spellId), knownSpells.end()); + markSinglePlayerDirty(SP_DIRTY_SPELLS, true); LOG_INFO("Removed spell: ", spellId); } @@ -2373,6 +3039,7 @@ void GameHandler::lootItem(uint8_t slotIndex) { if (inventory.addItem(def)) { simulateLootRemove(slotIndex); addSystemChatMessage("You receive item: " + def.name + " x" + std::to_string(def.stackCount) + "."); + markSinglePlayerDirty(SP_DIRTY_INVENTORY, true); if (currentLoot.lootGuid != 0) { auto st = localLootState_.find(currentLoot.lootGuid); if (st != localLootState_.end()) { @@ -2739,6 +3406,7 @@ void GameHandler::awardLocalXp(uint64_t victimGuid, uint32_t victimLevel) { if (xp == 0) return; playerXp_ += xp; + markSinglePlayerDirty(SP_DIRTY_XP, true); // Show XP gain in combat text as a heal-type (gold text) addCombatText(CombatTextEntry::HEAL, static_cast(xp), 0, true); @@ -2761,6 +3429,7 @@ void GameHandler::levelUp() { uint32_t newMaxHp = 20 + localPlayerLevel_ * 10; localPlayerMaxHealth_ = newMaxHp; localPlayerHealth_ = newMaxHp; // Full heal on level-up + markSinglePlayerDirty(SP_DIRTY_STATS | SP_DIRTY_XP, true); LOG_INFO("LEVEL UP! Now level ", localPlayerLevel_, " (HP: ", newMaxHp, ", next level: ", playerNextLevelXp_, " XP)"); @@ -2955,6 +3624,7 @@ void GameHandler::simulateMotd(const std::vector& lines) { void GameHandler::addMoneyCopper(uint32_t amount) { if (amount == 0) return; playerMoneyCopper_ += amount; + markSinglePlayerDirty(SP_DIRTY_MONEY, true); uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; uint32_t copper = amount % 100; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8537ec2e..0344bb4d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -94,10 +94,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Inventory (B key toggle handled inside) inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); + if (inventoryScreen.consumeInventoryDirty()) { + gameHandler.notifyInventoryChanged(); + } + if (inventoryScreen.consumeEquipmentDirty()) { updateCharacterGeosets(gameHandler.getInventory()); updateCharacterTextures(gameHandler.getInventory()); core::Application::getInstance().loadEquippedWeapons(); + gameHandler.notifyEquipmentChanged(); } // Update renderer face-target position diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index d0036ef2..90b4ab19 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -72,6 +72,7 @@ void InventoryScreen::pickupFromBackpack(game::Inventory& inv, int index) { heldBackpackIndex = index; heldEquipSlot = game::EquipSlot::NUM_SLOTS; inv.clearBackpackSlot(index); + inventoryDirty = true; } void InventoryScreen::pickupFromEquipment(game::Inventory& inv, game::EquipSlot slot) { @@ -84,6 +85,7 @@ void InventoryScreen::pickupFromEquipment(game::Inventory& inv, game::EquipSlot heldEquipSlot = slot; inv.clearEquipSlot(slot); equipmentDirty = true; + inventoryDirty = true; } void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { @@ -101,6 +103,7 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { heldSource = HeldSource::BACKPACK; heldBackpackIndex = index; } + inventoryDirty = true; } void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slot) { @@ -155,6 +158,7 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo } equipmentDirty = true; + inventoryDirty = true; } void InventoryScreen::cancelPickup(game::Inventory& inv) { @@ -180,6 +184,7 @@ void InventoryScreen::cancelPickup(game::Inventory& inv) { inv.addItem(heldItem); } holdingItem = false; + inventoryDirty = true; } void InventoryScreen::renderHeldItem() { @@ -529,6 +534,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite inventory.setBackpackSlot(freeSlot, item); inventory.clearEquipSlot(equipSlot); equipmentDirty = true; + inventoryDirty = true; } } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0 && item.inventoryType > 0) { // Auto-equip: find the right slot @@ -561,6 +567,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite inventory.clearEquipSlot(game::EquipSlot::MAIN_HAND); } equipmentDirty = true; + inventoryDirty = true; } } }