diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 294c0a82..ab99eaf6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -204,6 +204,23 @@ public: const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } + // Single-player mode + void setSinglePlayerMode(bool sp) { singlePlayerMode_ = sp; } + bool isSinglePlayerMode() const { return singlePlayerMode_; } + + // NPC death callback (single-player) + using NpcDeathCallback = std::function; + void setNpcDeathCallback(NpcDeathCallback cb) { npcDeathCallback_ = std::move(cb); } + + // Local player stats (single-player) + uint32_t getLocalPlayerHealth() const { return localPlayerHealth_; } + uint32_t getLocalPlayerMaxHealth() const { return localPlayerMaxHealth_; } + void initLocalPlayerStats(uint32_t level, uint32_t hp, uint32_t maxHp) { + localPlayerLevel_ = level; + localPlayerHealth_ = hp; + localPlayerMaxHealth_ = maxHp; + } + // Hearthstone callback (single-player teleport) using HearthstoneCallback = std::function; void setHearthstoneCallback(HearthstoneCallback cb) { hearthstoneCallback = std::move(cb); } @@ -468,6 +485,29 @@ private: // Callbacks WorldConnectSuccessCallback onSuccess; WorldConnectFailureCallback onFailure; + + // ---- Single-player combat ---- + bool singlePlayerMode_ = false; + float swingTimer_ = 0.0f; + static constexpr float SWING_SPEED = 2.0f; + NpcDeathCallback npcDeathCallback_; + uint32_t localPlayerHealth_ = 0; + uint32_t localPlayerMaxHealth_ = 0; + uint32_t localPlayerLevel_ = 1; + + struct NpcAggroEntry { + uint64_t guid; + float swingTimer; + }; + std::vector aggroList_; + + void updateLocalCombat(float deltaTime); + void updateNpcAggro(float deltaTime); + void performPlayerSwing(); + void performNpcSwing(uint64_t guid); + void handleNpcDeath(uint64_t guid); + void aggroNpc(uint64_t guid); + bool isNpcAggroed(uint64_t guid) const; }; } // namespace game diff --git a/include/game/npc_manager.hpp b/include/game/npc_manager.hpp index 5332fefa..81b405c3 100644 --- a/include/game/npc_manager.hpp +++ b/include/game/npc_manager.hpp @@ -47,6 +47,8 @@ public: const rendering::TerrainManager* terrainManager); void update(float deltaTime, rendering::CharacterRenderer* cr); + uint32_t findRenderInstanceId(uint64_t guid) const; + private: std::vector loadSpawnDefsFromFile(const std::string& path) const; std::vector loadSpawnDefsFromAzerothCoreDb( diff --git a/src/core/application.cpp b/src/core/application.cpp index 5797fe70..f0a4766f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -885,6 +885,18 @@ void Application::spawnNpcs() { gameHandler->setPosition(canonical.x, canonical.y, canonical.z); } + // Set NPC death callback for single-player combat + if (singlePlayerMode && gameHandler && npcManager) { + auto* npcMgr = npcManager.get(); + auto* cr = renderer->getCharacterRenderer(); + gameHandler->setNpcDeathCallback([npcMgr, cr](uint64_t guid) { + uint32_t instanceId = npcMgr->findRenderInstanceId(guid); + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death + } + }); + } + npcsSpawned = true; LOG_INFO("NPCs spawned for in-game session"); } @@ -895,6 +907,14 @@ void Application::startSinglePlayer() { // Set single-player flag singlePlayerMode = true; + // Enable single-player combat mode on game handler + if (gameHandler) { + gameHandler->setSinglePlayerMode(true); + uint32_t level = 10; + uint32_t maxHealth = 20 + level * 10; + gameHandler->initLocalPlayerStats(level, maxHealth, maxHealth); + } + // Create world object for single-player if (!world) { world = std::make_unique(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 950e188e..97c0a96a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -97,12 +97,14 @@ bool GameHandler::isConnected() const { } void GameHandler::update(float deltaTime) { - if (!socket) { + if (!socket && !singlePlayerMode_) { return; } // Update socket (processes incoming data and triggers callbacks) - socket->update(); + if (socket) { + socket->update(); + } // Validate target still exists if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) { @@ -110,11 +112,13 @@ void GameHandler::update(float deltaTime) { } // Send periodic heartbeat if in world - if (state == WorldState::IN_WORLD) { + if (state == WorldState::IN_WORLD || singlePlayerMode_) { timeSinceLastPing += deltaTime; if (timeSinceLastPing >= pingInterval) { - sendPing(); + if (socket) { + sendPing(); + } timeSinceLastPing = 0.0f; } @@ -148,6 +152,12 @@ void GameHandler::update(float deltaTime) { // Update combat text (Phase 2) updateCombatText(deltaTime); + + // Single-player local combat + if (singlePlayerMode_) { + updateLocalCombat(deltaTime); + updateNpcAggro(deltaTime); + } } } @@ -1114,6 +1124,7 @@ void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { void GameHandler::startAutoAttack(uint64_t targetGuid) { autoAttacking = true; autoAttackTarget = targetGuid; + swingTimer_ = 0.0f; if (state == WorldState::IN_WORLD && socket) { auto packet = AttackSwingPacket::build(targetGuid); socket->send(packet); @@ -1608,6 +1619,182 @@ void GameHandler::handleListInventory(network::Packet& packet) { gossipWindowOpen = false; // Close gossip if vendor opens } +// ============================================================ +// Single-player local combat +// ============================================================ + +void GameHandler::updateLocalCombat(float deltaTime) { + if (!autoAttacking || autoAttackTarget == 0) return; + + auto entity = entityManager.getEntity(autoAttackTarget); + if (!entity || entity->getType() != ObjectType::UNIT) { + stopAutoAttack(); + return; + } + auto unit = std::static_pointer_cast(entity); + if (unit->getHealth() == 0) { + stopAutoAttack(); + return; + } + + // Check melee range (~8 units squared distance) + float dx = unit->getX() - movementInfo.x; + float dy = unit->getY() - movementInfo.y; + float dz = unit->getZ() - movementInfo.z; + float distSq = dx * dx + dy * dy + dz * dz; + if (distSq > 64.0f) return; // 8^2 = 64 + + swingTimer_ += deltaTime; + while (swingTimer_ >= SWING_SPEED) { + swingTimer_ -= SWING_SPEED; + performPlayerSwing(); + } +} + +void GameHandler::performPlayerSwing() { + if (autoAttackTarget == 0) return; + auto entity = entityManager.getEntity(autoAttackTarget); + if (!entity || entity->getType() != ObjectType::UNIT) return; + auto unit = std::static_pointer_cast(entity); + if (unit->getHealth() == 0) return; + + // Aggro the target + aggroNpc(autoAttackTarget); + + // 5% miss chance + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution roll(0.0f, 1.0f); + if (roll(rng) < 0.05f) { + addCombatText(CombatTextEntry::MISS, 0, 0, true); + return; + } + + // Damage calculation + int32_t baseDamage = 5 + static_cast(localPlayerLevel_) * 3; + std::uniform_real_distribution dmgRange(0.8f, 1.2f); + int32_t damage = static_cast(baseDamage * dmgRange(rng)); + + // 10% crit chance (2x damage) + bool crit = roll(rng) < 0.10f; + if (crit) damage *= 2; + + // Apply damage + uint32_t hp = unit->getHealth(); + if (static_cast(damage) >= hp) { + unit->setHealth(0); + handleNpcDeath(autoAttackTarget); + } else { + unit->setHealth(hp - static_cast(damage)); + } + + addCombatText(crit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE, + damage, 0, true); +} + +void GameHandler::handleNpcDeath(uint64_t guid) { + // Remove from aggro list + aggroList_.erase( + std::remove_if(aggroList_.begin(), aggroList_.end(), + [guid](const NpcAggroEntry& e) { return e.guid == guid; }), + aggroList_.end()); + + // Stop auto-attack if target was this NPC + if (autoAttackTarget == guid) { + stopAutoAttack(); + } + + // Notify death callback (plays death animation) + if (npcDeathCallback_) { + npcDeathCallback_(guid); + } +} + +void GameHandler::aggroNpc(uint64_t guid) { + if (!isNpcAggroed(guid)) { + aggroList_.push_back({guid, 0.0f}); + } +} + +bool GameHandler::isNpcAggroed(uint64_t guid) const { + for (const auto& e : aggroList_) { + if (e.guid == guid) return true; + } + return false; +} + +void GameHandler::updateNpcAggro(float deltaTime) { + // Remove dead/missing NPCs and NPCs out of leash range + for (auto it = aggroList_.begin(); it != aggroList_.end(); ) { + auto entity = entityManager.getEntity(it->guid); + if (!entity || entity->getType() != ObjectType::UNIT) { + it = aggroList_.erase(it); + continue; + } + auto unit = std::static_pointer_cast(entity); + if (unit->getHealth() == 0) { + it = aggroList_.erase(it); + continue; + } + + // Leash range: 40 units + float dx = unit->getX() - movementInfo.x; + float dy = unit->getY() - movementInfo.y; + float distSq = dx * dx + dy * dy; + if (distSq > 1600.0f) { // 40^2 + it = aggroList_.erase(it); + continue; + } + + // Melee range: 8 units — NPC attacks player + float dz = unit->getZ() - movementInfo.z; + float fullDistSq = distSq + dz * dz; + if (fullDistSq <= 64.0f) { // 8^2 + it->swingTimer += deltaTime; + if (it->swingTimer >= SWING_SPEED) { + it->swingTimer -= SWING_SPEED; + performNpcSwing(it->guid); + } + } + ++it; + } +} + +void GameHandler::performNpcSwing(uint64_t guid) { + if (localPlayerHealth_ == 0) return; + + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::UNIT) return; + auto unit = std::static_pointer_cast(entity); + + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution roll(0.0f, 1.0f); + + // 5% miss + if (roll(rng) < 0.05f) { + addCombatText(CombatTextEntry::MISS, 0, 0, false); + return; + } + + // Damage: 3 + npcLevel * 2 + int32_t baseDamage = 3 + static_cast(unit->getLevel()) * 2; + std::uniform_real_distribution dmgRange(0.8f, 1.2f); + int32_t damage = static_cast(baseDamage * dmgRange(rng)); + + // 5% crit (2x) + bool crit = roll(rng) < 0.05f; + if (crit) damage *= 2; + + // Apply to local player health + if (static_cast(damage) >= localPlayerHealth_) { + localPlayerHealth_ = 0; + } else { + localPlayerHealth_ -= static_cast(damage); + } + + addCombatText(crit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE, + damage, 0, false); +} + uint32_t GameHandler::generateClientSeed() { // Generate cryptographically random seed std::random_device rd; diff --git a/src/game/npc_manager.cpp b/src/game/npc_manager.cpp index 756f8e1d..29869b20 100644 --- a/src/game/npc_manager.cpp +++ b/src/game/npc_manager.cpp @@ -788,6 +788,13 @@ void NpcManager::initialize(pipeline::AssetManager* am, loadedModels.size(), " unique models"); } +uint32_t NpcManager::findRenderInstanceId(uint64_t guid) const { + for (const auto& npc : npcs) { + if (npc.guid == guid) return npc.renderInstanceId; + } + return 0; +} + void NpcManager::update(float deltaTime, rendering::CharacterRenderer* cr) { if (!cr) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4aff77b4..2db68923 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -437,6 +437,13 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto unit = std::static_pointer_cast(target); if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { gameHandler.lootTarget(target->getGuid()); + } else if (gameHandler.isSinglePlayerMode()) { + // Single-player: toggle auto-attack + if (gameHandler.isAutoAttacking()) { + gameHandler.stopAutoAttack(); + } else { + gameHandler.startAutoAttack(target->getGuid()); + } } else { // Try NPC interaction first (gossip), fall back to attack gameHandler.interactWithNpc(target->getGuid()); @@ -494,6 +501,12 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } + // Override with local player stats in single-player mode + if (gameHandler.isSinglePlayerMode() && gameHandler.getLocalPlayerMaxHealth() > 0) { + playerHp = gameHandler.getLocalPlayerHealth(); + playerMaxHp = gameHandler.getLocalPlayerMaxHealth(); + } + // Health bar float pct = static_cast(playerHp) / static_cast(playerMaxHp); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f));