feat: implement spell queue window (400ms pre-cast)

When castSpell() is called while a timed cast is in progress and
castTimeRemaining <= 0.4s, store the spell in queuedSpellId_ instead
of silently dropping it.  handleSpellGo() fires the queued spell
immediately after clearing the cast state, matching the ~400ms spell
queue window in Blizzlike WoW clients.

Queue is cleared on all cancel/interrupt paths: cancelCast(),
handleCastFailed(), SMSG_CAST_RESULT failure, SMSG_SPELL_FAILED,
world-teardown, and worldport ACK.  Channeled casts never queue
(cancelling a channel should remain explicit).
This commit is contained in:
Kelsi 2026-03-18 00:21:46 -07:00
parent 0f8852d290
commit 4907f4124b
2 changed files with 38 additions and 3 deletions

View file

@ -2725,6 +2725,9 @@ private:
// Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes
uint32_t craftQueueSpellId_ = 0;
int craftQueueRemaining_ = 0;
// Spell queue: next spell to cast within the 400ms window before current cast ends
uint32_t queuedSpellId_ = 0;
uint64_t queuedSpellTarget_ = 0;
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
std::unordered_map<uint64_t, UnitCastState> unitCastStates_;
uint64_t pendingGameObjectInteractGuid_ = 0;

View file

@ -2252,9 +2252,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
lastInteractedGoGuid_ = 0;
// Cancel craft queue on cast failure
// Cancel craft queue and spell queue on cast failure
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
int playerPowerType = -1;
if (auto pe = entityManager.getEntity(playerGuid)) {
@ -3353,6 +3355,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
castIsChannel = false;
currentCastSpellId = 0;
lastInteractedGoGuid_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->stopPrecast();
@ -9090,6 +9094,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
lastInteractedGoGuid_ = 0;
castTimeRemaining = 0.0f;
castTimeTotal = 0.0f;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
playerDead_ = false;
releasedSpirit_ = false;
corpseGuid_ = 0;
@ -17933,7 +17939,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
return;
}
if (casting) return; // Already casting
if (casting) {
// Spell queue: if we're within 400ms of the cast completing (and not channeling),
// store the spell so it fires automatically when the cast finishes.
if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) {
queuedSpellId_ = spellId;
queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid;
LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f,
"ms remaining)");
}
return;
}
// Hearthstone: cast spell directly (server checks item in inventory)
// Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which
@ -18035,9 +18051,11 @@ void GameHandler::cancelCast() {
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Cancel craft queue when player manually cancels cast
// Cancel craft queue and spell queue when player manually cancels cast
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
}
void GameHandler::startCraftQueue(uint32_t spellId, int count) {
@ -18311,6 +18329,8 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
lastInteractedGoGuid_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Stop precast sound — spell failed before completing
if (auto* renderer = core::Application::getInstance().getRenderer()) {
@ -18483,6 +18503,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, false, false);
}
// Spell queue: fire the next queued spell now that casting has ended
if (queuedSpellId_ != 0) {
uint32_t nextSpell = queuedSpellId_;
uint64_t nextTarget = queuedSpellTarget_;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
LOG_INFO("Spell queue: firing queued spellId=", nextSpell);
castSpell(nextSpell, nextTarget);
}
} else {
if (spellCastAnimCallback_) {
// End cast animation on other unit
@ -22063,6 +22093,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
pendingGameObjectInteractGuid_ = 0;
lastInteractedGoGuid_ = 0;
castTimeRemaining = 0.0f;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
if (socket) {