mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 17:43:52 +00:00
Add single-player local combat system with auto-attack, NPC aggro, and death
This commit is contained in:
parent
b16578e2b9
commit
ed5d10ec01
6 changed files with 273 additions and 4 deletions
|
|
@ -204,6 +204,23 @@ public:
|
||||||
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; }
|
||||||
|
|
||||||
|
// Single-player mode
|
||||||
|
void setSinglePlayerMode(bool sp) { singlePlayerMode_ = sp; }
|
||||||
|
bool isSinglePlayerMode() const { return singlePlayerMode_; }
|
||||||
|
|
||||||
|
// NPC death callback (single-player)
|
||||||
|
using NpcDeathCallback = std::function<void(uint64_t guid)>;
|
||||||
|
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)
|
// Hearthstone callback (single-player teleport)
|
||||||
using HearthstoneCallback = std::function<void()>;
|
using HearthstoneCallback = std::function<void()>;
|
||||||
void setHearthstoneCallback(HearthstoneCallback cb) { hearthstoneCallback = std::move(cb); }
|
void setHearthstoneCallback(HearthstoneCallback cb) { hearthstoneCallback = std::move(cb); }
|
||||||
|
|
@ -468,6 +485,29 @@ private:
|
||||||
// Callbacks
|
// Callbacks
|
||||||
WorldConnectSuccessCallback onSuccess;
|
WorldConnectSuccessCallback onSuccess;
|
||||||
WorldConnectFailureCallback onFailure;
|
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<NpcAggroEntry> 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
|
} // namespace game
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ public:
|
||||||
const rendering::TerrainManager* terrainManager);
|
const rendering::TerrainManager* terrainManager);
|
||||||
void update(float deltaTime, rendering::CharacterRenderer* cr);
|
void update(float deltaTime, rendering::CharacterRenderer* cr);
|
||||||
|
|
||||||
|
uint32_t findRenderInstanceId(uint64_t guid) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<NpcSpawnDef> loadSpawnDefsFromFile(const std::string& path) const;
|
std::vector<NpcSpawnDef> loadSpawnDefsFromFile(const std::string& path) const;
|
||||||
std::vector<NpcSpawnDef> loadSpawnDefsFromAzerothCoreDb(
|
std::vector<NpcSpawnDef> loadSpawnDefsFromAzerothCoreDb(
|
||||||
|
|
|
||||||
|
|
@ -885,6 +885,18 @@ void Application::spawnNpcs() {
|
||||||
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
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;
|
npcsSpawned = true;
|
||||||
LOG_INFO("NPCs spawned for in-game session");
|
LOG_INFO("NPCs spawned for in-game session");
|
||||||
}
|
}
|
||||||
|
|
@ -895,6 +907,14 @@ void Application::startSinglePlayer() {
|
||||||
// Set single-player flag
|
// Set single-player flag
|
||||||
singlePlayerMode = true;
|
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
|
// Create world object for single-player
|
||||||
if (!world) {
|
if (!world) {
|
||||||
world = std::make_unique<game::World>();
|
world = std::make_unique<game::World>();
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,14 @@ bool GameHandler::isConnected() const {
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameHandler::update(float deltaTime) {
|
void GameHandler::update(float deltaTime) {
|
||||||
if (!socket) {
|
if (!socket && !singlePlayerMode_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update socket (processes incoming data and triggers callbacks)
|
// Update socket (processes incoming data and triggers callbacks)
|
||||||
socket->update();
|
if (socket) {
|
||||||
|
socket->update();
|
||||||
|
}
|
||||||
|
|
||||||
// Validate target still exists
|
// Validate target still exists
|
||||||
if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) {
|
if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) {
|
||||||
|
|
@ -110,11 +112,13 @@ void GameHandler::update(float deltaTime) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send periodic heartbeat if in world
|
// Send periodic heartbeat if in world
|
||||||
if (state == WorldState::IN_WORLD) {
|
if (state == WorldState::IN_WORLD || singlePlayerMode_) {
|
||||||
timeSinceLastPing += deltaTime;
|
timeSinceLastPing += deltaTime;
|
||||||
|
|
||||||
if (timeSinceLastPing >= pingInterval) {
|
if (timeSinceLastPing >= pingInterval) {
|
||||||
sendPing();
|
if (socket) {
|
||||||
|
sendPing();
|
||||||
|
}
|
||||||
timeSinceLastPing = 0.0f;
|
timeSinceLastPing = 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +152,12 @@ void GameHandler::update(float deltaTime) {
|
||||||
|
|
||||||
// Update combat text (Phase 2)
|
// Update combat text (Phase 2)
|
||||||
updateCombatText(deltaTime);
|
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) {
|
void GameHandler::startAutoAttack(uint64_t targetGuid) {
|
||||||
autoAttacking = true;
|
autoAttacking = true;
|
||||||
autoAttackTarget = targetGuid;
|
autoAttackTarget = targetGuid;
|
||||||
|
swingTimer_ = 0.0f;
|
||||||
if (state == WorldState::IN_WORLD && socket) {
|
if (state == WorldState::IN_WORLD && socket) {
|
||||||
auto packet = AttackSwingPacket::build(targetGuid);
|
auto packet = AttackSwingPacket::build(targetGuid);
|
||||||
socket->send(packet);
|
socket->send(packet);
|
||||||
|
|
@ -1608,6 +1619,182 @@ void GameHandler::handleListInventory(network::Packet& packet) {
|
||||||
gossipWindowOpen = false; // Close gossip if vendor opens
|
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<Unit>(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<Unit>(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<float> 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<int32_t>(localPlayerLevel_) * 3;
|
||||||
|
std::uniform_real_distribution<float> dmgRange(0.8f, 1.2f);
|
||||||
|
int32_t damage = static_cast<int32_t>(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<uint32_t>(damage) >= hp) {
|
||||||
|
unit->setHealth(0);
|
||||||
|
handleNpcDeath(autoAttackTarget);
|
||||||
|
} else {
|
||||||
|
unit->setHealth(hp - static_cast<uint32_t>(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<Unit>(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<Unit>(entity);
|
||||||
|
|
||||||
|
static std::mt19937 rng(std::random_device{}());
|
||||||
|
std::uniform_real_distribution<float> 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<int32_t>(unit->getLevel()) * 2;
|
||||||
|
std::uniform_real_distribution<float> dmgRange(0.8f, 1.2f);
|
||||||
|
int32_t damage = static_cast<int32_t>(baseDamage * dmgRange(rng));
|
||||||
|
|
||||||
|
// 5% crit (2x)
|
||||||
|
bool crit = roll(rng) < 0.05f;
|
||||||
|
if (crit) damage *= 2;
|
||||||
|
|
||||||
|
// Apply to local player health
|
||||||
|
if (static_cast<uint32_t>(damage) >= localPlayerHealth_) {
|
||||||
|
localPlayerHealth_ = 0;
|
||||||
|
} else {
|
||||||
|
localPlayerHealth_ -= static_cast<uint32_t>(damage);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCombatText(crit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE,
|
||||||
|
damage, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t GameHandler::generateClientSeed() {
|
uint32_t GameHandler::generateClientSeed() {
|
||||||
// Generate cryptographically random seed
|
// Generate cryptographically random seed
|
||||||
std::random_device rd;
|
std::random_device rd;
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,13 @@ void NpcManager::initialize(pipeline::AssetManager* am,
|
||||||
loadedModels.size(), " unique models");
|
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) {
|
void NpcManager::update(float deltaTime, rendering::CharacterRenderer* cr) {
|
||||||
if (!cr) return;
|
if (!cr) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,13 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
|
||||||
auto unit = std::static_pointer_cast<game::Unit>(target);
|
auto unit = std::static_pointer_cast<game::Unit>(target);
|
||||||
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
|
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
|
||||||
gameHandler.lootTarget(target->getGuid());
|
gameHandler.lootTarget(target->getGuid());
|
||||||
|
} else if (gameHandler.isSinglePlayerMode()) {
|
||||||
|
// Single-player: toggle auto-attack
|
||||||
|
if (gameHandler.isAutoAttacking()) {
|
||||||
|
gameHandler.stopAutoAttack();
|
||||||
|
} else {
|
||||||
|
gameHandler.startAutoAttack(target->getGuid());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try NPC interaction first (gossip), fall back to attack
|
// Try NPC interaction first (gossip), fall back to attack
|
||||||
gameHandler.interactWithNpc(target->getGuid());
|
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
|
// Health bar
|
||||||
float pct = static_cast<float>(playerHp) / static_cast<float>(playerMaxHp);
|
float pct = static_cast<float>(playerHp) / static_cast<float>(playerMaxHp);
|
||||||
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue