feat: flash action bar button red when spell cast fails

Add SpellCastFailedCallback to GameHandler, fired from SMSG_CAST_RESULT
when result != 0. GameScreen registers the callback and records each failed
spellId in actionFlashEndTimes_ (keyed by spell ID, value = expiry time).

During action bar rendering, if a slot's spell has an active flash entry,
an AddRectFilled overlay is drawn over the button with alpha proportional
to remaining time (1.0→0.0 over 0.5 s), giving the same error-red flash
visual feedback as the original WoW client.
This commit is contained in:
Kelsi 2026-03-18 04:30:33 -07:00
parent c1765b6b39
commit 277a26b351
4 changed files with 41 additions and 0 deletions

View file

@ -939,6 +939,10 @@ public:
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel)>;
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
// Fired when the player's own spell cast fails (spellId of the failed spell).
using SpellCastFailedCallback = std::function<void(uint32_t spellId)>;
void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); }
// Unit animation hint: signal jump (animId=38) for other players/NPCs
using UnitAnimHintCallback = std::function<void(uint64_t guid, uint32_t animId)>;
void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); }
@ -3309,6 +3313,7 @@ private:
MeleeSwingCallback meleeSwingCallback_;
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing
SpellCastAnimCallback spellCastAnimCallback_;
SpellCastFailedCallback spellCastFailedCallback_;
UnitAnimHintCallback unitAnimHintCallback_;
UnitMoveFlagsCallback unitMoveFlagsCallback_;
NpcSwingCallback npcSwingCallback_;

View file

@ -58,6 +58,10 @@ private:
// Set to true by /stopmacro; checked in executeMacroText to halt remaining commands.
bool macroStopped_ = false;
// Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends.
// Populated by the SpellCastFailedCallback; queried during action bar button rendering.
std::unordered_map<uint32_t, float> actionFlashEndTimes_;
// Tab-completion state for slash commands
std::string chatTabPrefix_; // prefix captured on first Tab press
std::vector<std::string> chatTabMatches_; // matching command list
@ -109,6 +113,8 @@ private:
std::vector<UIErrorEntry> uiErrors_;
bool uiErrorCallbackSet_ = false;
static constexpr float kUIErrorLifetime = 2.5f;
bool castFailedCallbackSet_ = false;
static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade
// Reputation change toast: brief colored slide-in below minimap
struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; };

View file

@ -2267,6 +2267,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
addUIError(errMsg);
if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;

View file

@ -414,6 +414,16 @@ void GameScreen::render(game::GameHandler& gameHandler) {
uiErrorCallbackSet_ = true;
}
// Flash the action bar button whose spell just failed (0.5 s red overlay).
if (!castFailedCallbackSet_) {
gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) {
if (spellId == 0) return;
float now = static_cast<float>(ImGui::GetTime());
actionFlashEndTimes_[spellId] = now + kActionFlashDuration;
});
castFailedCallbackSet_ = true;
}
// Set up reputation change toast callback (once)
if (!repChangeCallbackSet_) {
gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) {
@ -8435,6 +8445,25 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
}
// Error-flash overlay: red fade on spell cast failure (~0.5 s).
if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) {
auto flashIt = actionFlashEndTimes_.find(slot.id);
if (flashIt != actionFlashEndTimes_.end()) {
float now = static_cast<float>(ImGui::GetTime());
float remaining = flashIt->second - now;
if (remaining > 0.0f) {
float alpha = remaining / kActionFlashDuration; // 1→0
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
ImGui::GetWindowDrawList()->AddRectFilled(
rMin, rMax,
ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha)));
} else {
actionFlashEndTimes_.erase(flashIt);
}
}
}
bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
ImGui::IsMouseReleased(ImGuiMouseButton_Left);